From a72813acdeea950cbc60ad9b31af824745c8cb08 Mon Sep 17 00:00:00 2001 From: wabinyai Date: Fri, 14 Feb 2025 11:42:31 +0300 Subject: [PATCH 01/55] locate --- frontend/README.md | 11 +++ frontend/package-lock.json | 86 ++++++++++++++++++- frontend/package.json | 7 +- frontend/src/app/about/page.tsx | 56 ++++++++++++ frontend/src/app/not-found.tsx | 35 ++++++++ frontend/src/app/page.tsx | 5 ++ frontend/src/components/feature-card.tsx | 20 +++++ .../src/components/navigation/navigation.tsx | 45 ++++++++++ frontend/src/lib/utils.ts | 7 ++ frontend/src/ui/button.tsx | 56 ++++++++++++ 10 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/about/page.tsx create mode 100644 frontend/src/app/not-found.tsx create mode 100644 frontend/src/components/feature-card.tsx create mode 100644 frontend/src/components/navigation/navigation.tsx create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/ui/button.tsx diff --git a/frontend/README.md b/frontend/README.md index e215bc4..cea63ef 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,7 +1,18 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started +First intall +'npm install' +Have the .env file ready. +`.env` file. + + See the example at +`.env.example`. +``` +NEXT_PUBLIC_API_TOKEN=YOUR_TOKEN (get toke from airqo) +NEXT_PUBLIC_API_URL=https://analytics.airqo.net/api/v2/ +``` First, run the development server: ```bash diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8153ec4..f6a6bb5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,15 +8,20 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@radix-ui/react-slot": "^1.1.2", "axios": "^1.7.7", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "date-fns": "^4.1.0", "leaflet": "^1.9.4", "leaflet-geosearch": "^4.0.0", + "lucide-react": "^0.475.0", "next": "15.0.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", - "react-leaflet": "^4.2.1" + "react-leaflet": "^4.2.1", + "tailwind-merge": "^3.0.1" }, "devDependencies": { "@types/leaflet": "^1.9.14", @@ -774,6 +779,39 @@ "node": ">=14" } }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@react-leaflet/core": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", @@ -858,13 +896,13 @@ "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1583,11 +1621,32 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -1685,7 +1744,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -3615,6 +3674,15 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5029,6 +5097,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.1.tgz", + "integrity": "sha512-AvzE8FmSoXC7nC+oU5GlQJbip2UO7tmOhOfQyOmPhrStOGXHU08j8mZEHZ4BmCqY5dWTCo4ClWkNyRNx1wpT0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index b42fca6..b16ac8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,15 +9,20 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-slot": "^1.1.2", "axios": "^1.7.7", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "date-fns": "^4.1.0", "leaflet": "^1.9.4", "leaflet-geosearch": "^4.0.0", + "lucide-react": "^0.475.0", "next": "15.0.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", - "react-leaflet": "^4.2.1" + "react-leaflet": "^4.2.1", + "tailwind-merge": "^3.0.1" }, "devDependencies": { "@types/leaflet": "^1.9.14", diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx new file mode 100644 index 0000000..c4f7f71 --- /dev/null +++ b/frontend/src/app/about/page.tsx @@ -0,0 +1,56 @@ +import { FeatureCard } from "@/components/feature-card"; +import { MapPin, Users, BarChart3 } from "lucide-react"; +import Navigation from "@/components/navigation/navigation"; + +export default function AboutPage() { + return ( +
+ {/* Navigation Component */} + + +

About AirQo

+ +
+

+ AirQo is a pioneering initiative dedicated to improving air quality monitoring and management across Africa. + Our mission is to provide accurate, actionable air quality information to empower communities, researchers, + and policymakers in the fight against air pollution. +

+

+ Founded in 2015 at Makerere University in Uganda, AirQo has grown into a multidisciplinary team of engineers, + data scientists, and environmental experts. We're committed to developing innovative, low-cost air quality + monitoring solutions tailored for the unique challenges of African urban environments. +

+
+ +

Our Core Values

+
+ + + +
+ +
+

Our Impact

+
    +
  • Deployed over 100 low-cost air quality sensors across East Africa
  • +
  • Provided air quality data to millions of citizens through our mobile app and API
  • +
  • Collaborated with local governments to develop data-driven air quality management strategies
  • +
  • Engaged in capacity building, training over 500 individuals in air quality monitoring and analysis
  • +
+
+
+ ); +} diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx new file mode 100644 index 0000000..2f561bc --- /dev/null +++ b/frontend/src/app/not-found.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useEffect } from "react" +import { usePathname } from "next/navigation" +import Link from "next/link" + +const NotFound = () => { + const pathname = usePathname() + + useEffect(() => { + console.error("404 Error: User attempted to access non-existent route:", pathname) + }, [pathname]) + + return ( +
+
+

404

+

Oops! The page you're looking for doesn't exist.

+

+ You tried to access {pathname}, but + it's not available. +

+ + Return to Home + +
+
+ ) +} + +export default NotFound + diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 00e6a9e..35c3abf 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2,6 +2,7 @@ import React from "react"; import dynamic from "next/dynamic"; import Loading from "./Loading"; +import Navigation from "@/components/navigation/navigation"; const LeafletMap = dynamic(() => import("../components/map/LeafletMap"), { ssr: false, @@ -11,6 +12,10 @@ const LeafletMap = dynamic(() => import("../components/map/LeafletMap"), { const Home: React.FC = () => { return (
+ {/* Add Navigation Component */} + + + {/* Dynamically Loaded Map */}
); diff --git a/frontend/src/components/feature-card.tsx b/frontend/src/components/feature-card.tsx new file mode 100644 index 0000000..95e6339 --- /dev/null +++ b/frontend/src/components/feature-card.tsx @@ -0,0 +1,20 @@ +import type { LucideIcon } from "lucide-react" + +interface FeatureCardProps { + title: string + description: string + Icon: LucideIcon +} + +export function FeatureCard({ title, description, Icon }: FeatureCardProps) { + return ( +
+
+ +
+

{title}

+

{description}

+
+ ) +} + diff --git a/frontend/src/components/navigation/navigation.tsx b/frontend/src/components/navigation/navigation.tsx new file mode 100644 index 0000000..42169aa --- /dev/null +++ b/frontend/src/components/navigation/navigation.tsx @@ -0,0 +1,45 @@ +"use client" + +import type React from "react" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { cn } from "@/lib/utils" + +const navItems = [ + { name: "Home", href: "/" }, + { name: "About", href: "/about" }, + { name: "Locate", href: "/locate" }, + { name: "Categorize", href: "/categorize" }, + { name: "Reports", href: "/reports" }, +] + +export default function Navigation({ className, ...props }: React.HTMLAttributes) { + const pathname = usePathname() + + return ( +
+
+ + AirQo AI + + + +
+
+ ) +} + diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..d5d9011 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,7 @@ +// utils implementation +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} \ No newline at end of file diff --git a/frontend/src/ui/button.tsx b/frontend/src/ui/button.tsx new file mode 100644 index 0000000..36496a2 --- /dev/null +++ b/frontend/src/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } From f0f0c9a845d6f968511a7cb1f998b16755309ddb Mon Sep 17 00:00:00 2001 From: wabinyai Date: Fri, 14 Feb 2025 11:50:46 +0300 Subject: [PATCH 02/55] READ --- frontend/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index cea63ef..b6d21d6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,11 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started -First intall -'npm install' + ++First install dependencies: ++```bash ++npm install ++``` Have the .env file ready. `.env` file. From e0a1bf1ef512645ac5d3aac9a5b8e92cfa75249f Mon Sep 17 00:00:00 2001 From: wabinyai Date: Fri, 14 Feb 2025 11:55:25 +0300 Subject: [PATCH 03/55] dev --- frontend/README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index b6d21d6..96c70fa 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -2,18 +2,19 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next- ## Getting Started -+First install dependencies: -+```bash -+npm install -+``` +First install dependencies: +```bash +npm install +``` -Have the .env file ready. -`.env` file. + +Required environment variables: - See the example at -`.env.example`. -``` -NEXT_PUBLIC_API_TOKEN=YOUR_TOKEN (get toke from airqo) +```.env +# Your AirQo API token (obtain from AirQo dashboard) +NEXT_PUBLIC_API_TOKEN=YOUR_TOKEN + +# AirQo API endpoint NEXT_PUBLIC_API_URL=https://analytics.airqo.net/api/v2/ ``` First, run the development server: From c6d0e4dc2b60b83d5a23ffc2eb891247465d925e Mon Sep 17 00:00:00 2001 From: wabinyai Date: Fri, 14 Feb 2025 12:40:48 +0300 Subject: [PATCH 04/55] nav --- frontend/src/app/about/page.tsx | 88 ++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 41 deletions(-) diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx index c4f7f71..05be004 100644 --- a/frontend/src/app/about/page.tsx +++ b/frontend/src/app/about/page.tsx @@ -1,55 +1,61 @@ +"use client"; // Make sure to mark the file as a client component + +import React from "react"; import { FeatureCard } from "@/components/feature-card"; import { MapPin, Users, BarChart3 } from "lucide-react"; import Navigation from "@/components/navigation/navigation"; export default function AboutPage() { return ( -
- {/* Navigation Component */} +
+ {/* Navigation Component at the top for consistency */} -

About AirQo

+ {/* About Page Content */} +
+

About AirQo

-
-

- AirQo is a pioneering initiative dedicated to improving air quality monitoring and management across Africa. - Our mission is to provide accurate, actionable air quality information to empower communities, researchers, - and policymakers in the fight against air pollution. -

-

- Founded in 2015 at Makerere University in Uganda, AirQo has grown into a multidisciplinary team of engineers, - data scientists, and environmental experts. We're committed to developing innovative, low-cost air quality - monitoring solutions tailored for the unique challenges of African urban environments. -

-
+
+

+ AirQo is a pioneering initiative dedicated to improving air quality monitoring and management across Africa. + Our mission is to provide accurate, actionable air quality information to empower communities, researchers, + and policymakers in the fight against air pollution. +

+

+ Founded in 2015 at Makerere University in Uganda, AirQo has grown into a multidisciplinary team of engineers, + data scientists, and environmental experts. We're committed to developing innovative, low-cost air quality + monitoring solutions tailored for the unique challenges of African urban environments. +

+
-

Our Core Values

-
- - - -
+

Our Core Values

+
+ + + +
-
-

Our Impact

-
    -
  • Deployed over 100 low-cost air quality sensors across East Africa
  • -
  • Provided air quality data to millions of citizens through our mobile app and API
  • -
  • Collaborated with local governments to develop data-driven air quality management strategies
  • -
  • Engaged in capacity building, training over 500 individuals in air quality monitoring and analysis
  • -
+
+

Our Impact

+
    +
  • Deployed over 100 low-cost air quality sensors across East Africa
  • +
  • Provided air quality data to millions of citizens through our mobile app and API
  • +
  • Collaborated with local governments to develop data-driven air quality management strategies
  • +
  • Engaged in capacity building, training over 500 individuals in air quality monitoring and analysis
  • +
+
); From befe6e97924876246b5ae4d6e163e285bb37abaf Mon Sep 17 00:00:00 2001 From: wabinyai Date: Fri, 14 Feb 2025 15:34:55 +0300 Subject: [PATCH 05/55] local --- frontend/package-lock.json | 1415 ++++++++++++++++- frontend/package.json | 6 + frontend/src/app/locate/page.tsx | 139 ++ .../src/components/Controls/ControlPanel.tsx | 187 +++ .../src/components/Controls/FileUpload.tsx | 107 ++ .../src/components/Controls/SearchBar.tsx | 137 ++ frontend/src/components/hooks/use-toast.ts | 191 +++ frontend/src/components/map/MapComponent.tsx | 198 +++ .../src/components/map/NavigationControls.tsx | 22 + frontend/src/lib/api.ts | 116 ++ frontend/src/lib/types.ts | 146 ++ frontend/src/ui/input.tsx | 22 + frontend/src/ui/popover.tsx | 29 + frontend/src/ui/toast.tsx | 127 ++ frontend/src/ui/use-toast.ts | 3 + 15 files changed, 2830 insertions(+), 15 deletions(-) create mode 100644 frontend/src/app/locate/page.tsx create mode 100644 frontend/src/components/Controls/ControlPanel.tsx create mode 100644 frontend/src/components/Controls/FileUpload.tsx create mode 100644 frontend/src/components/Controls/SearchBar.tsx create mode 100644 frontend/src/components/hooks/use-toast.ts create mode 100644 frontend/src/components/map/MapComponent.tsx create mode 100644 frontend/src/components/map/NavigationControls.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/ui/input.tsx create mode 100644 frontend/src/ui/popover.tsx create mode 100644 frontend/src/ui/toast.tsx create mode 100644 frontend/src/ui/use-toast.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f6a6bb5..a8cb8f2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,15 +8,20 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-toast": "^1.2.6", + "@shadcn/ui": "^0.0.4", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "html2canvas": "^1.4.1", "leaflet": "^1.9.4", "leaflet-geosearch": "^4.0.0", "lucide-react": "^0.475.0", "next": "15.0.2", + "papaparse": "^5.5.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", @@ -27,6 +32,7 @@ "@types/leaflet": "^1.9.14", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20", + "@types/papaparse": "^5.3.15", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", @@ -116,6 +122,44 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@googlemaps/js-api-loader": { "version": "1.16.8", "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz", @@ -779,6 +823,61 @@ "node": ">=14" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -794,6 +893,246 @@ } } }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", @@ -812,6 +1151,171 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz", + "integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, "node_modules/@react-leaflet/core": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", @@ -834,6 +1338,46 @@ "integrity": "sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==", "dev": true }, + "node_modules/@shadcn/ui": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@shadcn/ui/-/ui-0.0.4.tgz", + "integrity": "sha512-0dtu/5ApsOZ24qgaZwtif8jVwqol7a4m1x5AxPuM1k5wxhqU7t/qEfBGtaSki1R8VlbTQfCj5PAlO45NKCa7Gg==", + "license": "MIT", + "dependencies": { + "chalk": "5.2.0", + "commander": "^10.0.0", + "execa": "^7.0.0", + "fs-extra": "^11.1.0", + "node-fetch": "^3.3.0", + "ora": "^6.1.2", + "prompts": "^2.4.2", + "zod": "^3.20.2" + }, + "bin": { + "ui": "dist/index.js" + } + }, + "node_modules/@shadcn/ui/node_modules/chalk": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz", + "integrity": "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@shadcn/ui/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -892,6 +1436,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/papaparse": { + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.15.tgz", + "integrity": "sha512-JHe6vF6x/8Z85nCX4yFdDslN11d+1pr12E526X8WAfhadOeaOTx5AuIkvDKIBopfvlzpzkdMx4YyvSKCM9oqtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", @@ -912,7 +1466,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", - "dev": true, + "devOptional": true, "dependencies": { "@types/react": "*" } @@ -1249,6 +1803,18 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -1468,6 +2034,35 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1480,6 +2075,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1502,6 +2108,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -1633,11 +2263,47 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1718,7 +2384,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1728,6 +2393,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1752,6 +2426,15 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -1835,6 +2518,18 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1886,6 +2581,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2558,6 +3259,35 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", + "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2613,6 +3343,29 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2730,6 +3483,32 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2805,6 +3584,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -2913,8 +3713,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -3003,6 +3802,48 @@ "node": ">= 0.4" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3051,8 +3892,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { "version": "1.0.7", @@ -3268,6 +4108,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -3368,6 +4220,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -3413,6 +4277,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -3462,8 +4338,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/iterator.prototype": { "version": "1.1.3", @@ -3552,6 +4427,18 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -3576,6 +4463,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -3657,6 +4553,34 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/log-symbols": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz", + "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==", + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0", + "is-unicode-supported": "^1.1.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3683,6 +4607,12 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3724,6 +4654,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3874,13 +4816,77 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/object-assign": { @@ -4012,6 +5018,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4029,6 +5050,68 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz", + "integrity": "sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ==", + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0", + "cli-cursor": "^4.0.0", + "cli-spinners": "^2.6.1", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^1.1.0", + "log-symbols": "^5.1.0", + "stdin-discarder": "^0.1.0", + "strip-ansi": "^7.0.1", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4065,6 +5148,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, + "node_modules/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4099,7 +5188,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -4334,6 +5422,19 @@ "node": ">= 0.8.0" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4429,6 +5530,75 @@ "react-dom": "^18.0.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", + "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4438,6 +5608,20 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4524,6 +5708,52 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -4591,6 +5821,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex-test": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", @@ -4703,7 +5953,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4715,7 +5964,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -4759,6 +6007,12 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4767,6 +6021,21 @@ "node": ">=0.10.0" } }, + "node_modules/stdin-discarder": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", + "integrity": "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==", + "license": "MIT", + "dependencies": { + "bl": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -4775,6 +6044,15 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -4973,6 +6251,18 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5153,6 +6443,15 @@ "node": ">=6" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -5358,6 +6657,15 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "dev": true }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5367,17 +6675,85 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -5599,6 +6975,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index b16ac8c..8e03eed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,15 +9,20 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-toast": "^1.2.6", + "@shadcn/ui": "^0.0.4", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "html2canvas": "^1.4.1", "leaflet": "^1.9.4", "leaflet-geosearch": "^4.0.0", "lucide-react": "^0.475.0", "next": "15.0.2", + "papaparse": "^5.5.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", @@ -28,6 +33,7 @@ "@types/leaflet": "^1.9.14", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20", + "@types/papaparse": "^5.3.15", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", diff --git a/frontend/src/app/locate/page.tsx b/frontend/src/app/locate/page.tsx new file mode 100644 index 0000000..7c14eb3 --- /dev/null +++ b/frontend/src/app/locate/page.tsx @@ -0,0 +1,139 @@ +"use client" + +import { useState } from 'react'; +import { MapComponent } from '@components/map/MapComponent'; +import { ControlPanel } from '@/components/Controls/ControlPanel'; +import { Location, SiteLocatorPayload } from '@/lib/types'; +import { submitLocations } from '@/lib/api'; +import { useToast } from '@/ui/use-toast'; +import { Button } from '@/ui/button'; +import { Download, Camera } from 'lucide-react'; +import html2canvas from 'html2canvas'; +import Navigation from "@/components/navigation/navigation"; + +export default function Index() { + const [polygon, setPolygon] = useState([]); + const [mustHaveLocations, setMustHaveLocations] = useState([]); + const [suggestedLocations, setSuggestedLocations] = useState([]); + const { toast } = useToast(); + + const handleSubmit = async (payload: SiteLocatorPayload) => { + try { + console.log('Submitting payload:', payload); // Debug log for request + const response = await submitLocations(payload); + console.log('API Response:', response); // Debug log for response + + if (!response.site_location || !Array.isArray(response.site_location)) { + throw new Error('Invalid response format from API'); + } + + const locations = response.site_location.map(site => ({ + lat: site.latitude, + lng: site.longitude, + })); + + console.log('Processed locations to plot:', locations); // Debug log for processed locations + setSuggestedLocations(locations); + + toast({ + title: 'Success', + description: `Found ${locations.length} suggested locations`, + }); + } catch (error) { + console.error('Submit error:', error); + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Failed to submit locations', + variant: 'destructive', + }); + } + }; + + const handleLocationClick = (location: Location) => { + setMustHaveLocations([...mustHaveLocations, location]); + }; + + const handleExportCSV = () => { + if (suggestedLocations.length === 0) { + toast({ + title: 'No Data', + description: 'No locations available to export', + variant: 'destructive', + }); + return; + } + + const headers = ['Type', 'Latitude', 'Longitude', 'Area Name', 'Category']; + const rows = [ + ...mustHaveLocations.map(loc => ['Must Have', loc.lat, loc.lng, '', '']), + ...suggestedLocations.map(loc => ['Suggested', loc.lat, loc.lng, '', '']), + ]; + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.join(',')), + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'locations.csv'; + a.click(); + window.URL.revokeObjectURL(url); + + toast({ + title: 'Success', + description: 'CSV file downloaded successfully', + }); + }; + + const handleSaveMap = async () => { + const mapElement = document.querySelector('.leaflet-container'); + if (mapElement) { + const canvas = await html2canvas(mapElement as HTMLElement); + const url = canvas.toDataURL('image/png'); + const a = document.createElement('a'); + a.href = url; + a.download = 'map.png'; + a.click(); + + toast({ + title: 'Success', + description: 'Map image saved successfully', + }); + } + }; + + return ( +
+ +
+ + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Controls/ControlPanel.tsx b/frontend/src/components/Controls/ControlPanel.tsx new file mode 100644 index 0000000..21255f2 --- /dev/null +++ b/frontend/src/components/Controls/ControlPanel.tsx @@ -0,0 +1,187 @@ +import { useState } from 'react'; +import { Button } from '@/ui/button'; +import { Input } from '@/ui/input'; +import { SearchBar } from './SearchBar'; +import { FileUpload } from './FileUpload'; +import { Location, ControlPanelProps } from '@/lib/types'; +import { useToast } from '@/ui/use-toast'; +import { Loader2 } from 'lucide-react'; + +// Extend ControlPanelProps to include onBoundaryFound +interface ExtendedControlPanelProps extends ControlPanelProps { + onBoundaryFound: (boundary: Location[]) => void; +} + +export function ControlPanel({ + onSubmit, + polygon, + mustHaveLocations, + onMustHaveLocationsChange, + onBoundaryFound, +}: ExtendedControlPanelProps) { + const [minDistance, setMinDistance] = useState('0.5'); // Default value for min_distance_km + const [numSensors, setNumSensors] = useState('5'); // Default value for num_sensors + const [newLat, setNewLat] = useState(''); + const [newLng, setNewLng] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + // Validation helper function + const validateInputs = () => { + if (polygon.length < 3) { + toast({ + title: 'Error', + description: 'Please draw a polygon on the map', + variant: 'destructive', + }); + return false; + } + if (!numSensors || parseInt(numSensors) < 1) { + toast({ + title: 'Error', + description: 'Please enter a valid number of sensors', + variant: 'destructive', + }); + return false; + } + return true; + }; + + // Handle form submission + const handleSubmit = async () => { + if (!validateInputs()) return; + + const payload = { + polygon: { + coordinates: [ + [...polygon.map((loc) => [loc.lng, loc.lat]), [polygon[0].lng, polygon[0].lat]], // Close the polygon + ], + }, + must_have_locations: mustHaveLocations.length > 0 ? mustHaveLocations.map((loc) => [loc.lat, loc.lng]) : [], + min_distance_km: parseFloat(minDistance) || undefined, // Optional field + num_sensors: parseInt(numSensors, 10), + }; + + setIsLoading(true); + try { + await onSubmit(payload); + toast({ + title: 'Success', + description: 'Locations submitted successfully', + }); + } catch (error) { + toast({ + title: 'Error', + description: 'Failed to submit locations', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; + + // Add a new must-have location + const handleAddLocation = () => { + const lat = parseFloat(newLat); + const lng = parseFloat(newLng); + + if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { + toast({ + title: 'Error', + description: 'Please enter valid latitude (-90 to 90) and longitude (-180 to 180)', + variant: 'destructive', + }); + return; + } + + onMustHaveLocationsChange([...mustHaveLocations, { lat, lng }]); + setNewLat(''); + setNewLng(''); + toast({ + title: 'Success', + description: 'Location added successfully', + }); + }; + + return ( +
+

Air Quality Site Locator

+ + {/* Search Bar */} + {}} onBoundaryFound={onBoundaryFound} /> + + {/* Must-Have Locations */} +
+ +
+ setNewLat(e.target.value)} + step="any" + aria-label="Latitude" + /> + setNewLng(e.target.value)} + step="any" + aria-label="Longitude" + /> + +
+ +
+ {mustHaveLocations.length} locations added +
+
+ + {/* Minimum Distance */} +
+ + setMinDistance(e.target.value)} + aria-label="Minimum Distance" + /> +
+ + {/*Add Number of Sensors */} +
+ + setNumSensors(e.target.value)} + required + aria-label="Number of Sensors" + /> +
+ + {/* Submit Button */} + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Controls/FileUpload.tsx b/frontend/src/components/Controls/FileUpload.tsx new file mode 100644 index 0000000..4cdc2e4 --- /dev/null +++ b/frontend/src/components/Controls/FileUpload.tsx @@ -0,0 +1,107 @@ +"use client" + +import { useRef } from 'react'; +import { Button } from '@/ui/button'; +import { Upload } from 'lucide-react'; +import Papa from 'papaparse'; +import { useToast } from '@/ui/use-toast'; + +interface Location { + lat: number; + lng: number; +} + +interface FileUploadProps { + onUpload: (locations: Location[]) => void; +} + +export function FileUpload({ onUpload }: FileUploadProps) { + const fileInputRef = useRef(null); + const { toast } = useToast(); + + const normalizeColumnName = (name: string): string => + name.trim().toLowerCase().replace(/\s+/g, ''); + + const latitudeAliases = ['lat', 'latitude']; + const longitudeAliases = ['lng', 'lon', 'longitude']; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + Papa.parse(file, { + header: true, + complete: (results) => { + try { + const headers = results.meta.fields || []; + if (!headers.length) { + throw new Error('CSV file has no headers.'); + } + + const normalizedHeaders = headers.map(normalizeColumnName); + const latIndex = normalizedHeaders.findIndex((header) => + latitudeAliases.includes(header) + ); + const lngIndex = normalizedHeaders.findIndex((header) => + longitudeAliases.includes(header) + ); + + if (latIndex === -1 || lngIndex === -1) { + throw new Error('Could not find latitude and/or longitude columns in the CSV file.'); + } + + const locations = results.data + .map((row: any) => ({ + lat: parseFloat(row[headers[latIndex]]), + lng: parseFloat(row[headers[lngIndex]]), + })) + .filter((loc: Location) => !isNaN(loc.lat) && !isNaN(loc.lng)); + + if (locations.length === 0) { + throw new Error('No valid latitude/longitude pairs found.'); + } + + onUpload(locations); + + toast({ + title: 'Success', + description: `Imported ${locations.length} locations from CSV`, + }); + } catch (error) { + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Failed to parse CSV file.', + variant: 'destructive', + }); + } + }, + error: () => { + toast({ + title: 'Error', + description: 'Failed to read CSV file', + variant: 'destructive', + }); + }, + }); + }; + + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/Controls/SearchBar.tsx b/frontend/src/components/Controls/SearchBar.tsx new file mode 100644 index 0000000..009016b --- /dev/null +++ b/frontend/src/components/Controls/SearchBar.tsx @@ -0,0 +1,137 @@ +import { useState, useEffect } from "react"; +import { Input } from "@/ui/input"; +import { Button } from "@/ui/button"; +import { Search, X } from "lucide-react"; +import { useToast } from "@/ui/use-toast"; +import { Location } from "@/lib/types"; + +interface SearchBarProps { + onSearch: (query: string) => void; + onBoundaryFound: (boundary: Location[]) => void; +} + +export function SearchBar({ onSearch, onBoundaryFound }: SearchBarProps) { + const [query, setQuery] = useState(""); + const [suggestions, setSuggestions] = useState<{ name: string; osm_id: number }[]>([]); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + // Fetch autocomplete suggestions as user types + useEffect(() => { + const fetchSuggestions = async () => { + if (query.length < 2) { + setSuggestions([]); // Hide suggestions if query is too short + return; + } + + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5` + ); + const data = await response.json(); + setSuggestions(data.map((item: any) => ({ name: item.display_name, osm_id: item.osm_id }))); + } catch (error) { + console.error("Error fetching suggestions:", error); + } + }; + + const timeoutId = setTimeout(fetchSuggestions, 300); // Debounce API calls + return () => clearTimeout(timeoutId); + }, [query]); + + // Handle search (when user submits form or selects a suggestion) + const searchLocation = async (selectedQuery?: string, selectedOsmId?: number) => { + const searchQuery = selectedQuery || query; + if (!searchQuery.trim()) return; + + setIsLoading(true); + setSuggestions([]); // Hide suggestions after selection + + try { + const searchResponse = await fetch( + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(searchQuery)}&format=json&polygon_geojson=1&limit=1` + ); + const searchResults = await searchResponse.json(); + + if (searchResults.length === 0) { + toast({ title: "Location not found", description: "Try another search term.", variant: "destructive" }); + return; + } + + const osmId = selectedOsmId || searchResults[0].osm_id; + + // Fetch boundary data + const boundaryResponse = await fetch( + `https://nominatim.openstreetmap.org/lookup?osm_ids=R${osmId}&polygon_geojson=1&format=json` + ); + const boundaryData = await boundaryResponse.json(); + + if (boundaryData[0]?.geojson?.coordinates?.[0]) { + const boundary = boundaryData[0].geojson.coordinates[0].map(([lng, lat]: number[]) => ({ lat, lng })); + onBoundaryFound(boundary); + onSearch(searchQuery); + + // Center the map on the first coordinate of the boundary + if (window.map) { + const center = boundary[0]; + window.map.setView([center.lat, center.lng], 12); + } + + toast({ title: "Location found", description: `Boundary drawn for ${searchResults[0].display_name}` }); + } else { + toast({ title: "Boundary not found", description: "No boundary data available", variant: "destructive" }); + } + } catch (error) { + toast({ title: "Error", description: "Failed to search location", variant: "destructive" }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
{ e.preventDefault(); searchLocation(); }} className="flex gap-2"> + setQuery(e.target.value)} + className="flex-1" + disabled={isLoading} + /> + {query && ( + + )} + +
+ + {/* Dropdown Suggestions */} + {suggestions.length > 0 && ( +
    + {suggestions.map((suggestion, index) => ( +
  • { + setQuery(suggestion.name); + setSuggestions([]); // Hide suggestions + searchLocation(suggestion.name, suggestion.osm_id); + }} + > + {suggestion.name} +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/hooks/use-toast.ts b/frontend/src/components/hooks/use-toast.ts new file mode 100644 index 0000000..b390744 --- /dev/null +++ b/frontend/src/components/hooks/use-toast.ts @@ -0,0 +1,191 @@ +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/frontend/src/components/map/MapComponent.tsx b/frontend/src/components/map/MapComponent.tsx new file mode 100644 index 0000000..34e5296 --- /dev/null +++ b/frontend/src/components/map/MapComponent.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { MapContainer, TileLayer, useMap, Marker, Polygon } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { Location } from '@/lib/types'; +import { NavigationControls } from './NavigationControls'; +import { Button } from '@/ui/button'; +import { Map as MapIcon } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; + +// Fix for default markers +delete (L.Icon.Default.prototype as any)._getIconUrl; + +// Create custom icons for different marker types +const blueIcon = new L.Icon({ + iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', + shadowSize: [41, 41], +}); + +const greenIcon = new L.Icon({ + iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', + shadowSize: [41, 41], +}); + +const mapStyles = { + streets: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + satellite: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", +}; + +type MapStyle = keyof typeof mapStyles; + +interface MapComponentProps { + polygon: Location[]; + mustHaveLocations: Location[]; + suggestedLocations: Location[]; + onPolygonChange: (locations: Location[]) => void; + onLocationClick: (location: Location) => void; +} + +// Add map instance to window for global access +declare global { + interface Window { + map: L.Map; + } +} + +function MapStyleControl() { + const map = useMap(); + const [currentStyle, setCurrentStyle] = useState('streets'); + + const changeStyle = (style: MapStyle) => { + setCurrentStyle(style); + // Find and remove the existing tile layer + map.eachLayer((layer) => { + if (layer instanceof L.TileLayer) { + map.removeLayer(layer); + } + }); + // Add the new tile layer + L.tileLayer(mapStyles[style], { + attribution: style === 'satellite' + ? '© ESRI' + : '© OpenStreetMap contributors' + }).addTo(map); + }; + + return ( +
+ + + + + + {Object.keys(mapStyles).map((style) => ( + + ))} + + +
+ ); +} + +function MapController() { + const map = useMap(); + useEffect(() => { + window.map = map; + }, [map]); + return null; +} + +function DrawControl({ + onPolygonChange, +}: { + onPolygonChange: (locations: Location[]) => void; +}) { + const map = useMap(); + const drawingRef = useRef(); + const locationsRef = useRef([]); + + useEffect(() => { + const handleClick = (e: L.LeafletMouseEvent) => { + const newLocation = { lat: e.latlng.lat, lng: e.latlng.lng }; + locationsRef.current = [...locationsRef.current, newLocation]; + + if (!drawingRef.current) { + drawingRef.current = L.polyline([], { color: 'blue' }).addTo(map); + } + + drawingRef.current.setLatLngs(locationsRef.current); + onPolygonChange(locationsRef.current); + }; + + map.on('click', handleClick); + + return () => { + map.off('click', handleClick); + drawingRef.current?.remove(); + }; + }, [map, onPolygonChange]); + + return null; +} + +export function MapComponent({ + polygon, + mustHaveLocations, + suggestedLocations, + onPolygonChange, + onLocationClick, +}: MapComponentProps) { + const [isDrawing, setIsDrawing] = useState(false); + + return ( +
+ + + + + + {isDrawing && } + + {polygon.length > 2 && ( + [loc.lat, loc.lng])} + pathOptions={{ color: 'blue' }} + /> + )} + + {mustHaveLocations.map((location, index) => ( + + ))} + + {suggestedLocations.map((location, index) => ( + + ))} + + + setIsDrawing(!isDrawing)} + /> +
+ ); +} diff --git a/frontend/src/components/map/NavigationControls.tsx b/frontend/src/components/map/NavigationControls.tsx new file mode 100644 index 0000000..d8b6bae --- /dev/null +++ b/frontend/src/components/map/NavigationControls.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Button } from '@/ui/button'; + +interface NavigationControlsProps { + isDrawing: boolean; + onDrawingToggle: () => void; +} + +export function NavigationControls({ isDrawing, onDrawingToggle }: NavigationControlsProps) { + return ( +
+ +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..7fe15f0 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,116 @@ +import axios from "axios" +import { NextResponse } from "next/server" +import { Location, SiteLocatorPayload, SiteLocatorResponse, + SiteInformation, SiteLocation, SiteCategoryResponse, + ControlPanelProps, GridOption, AirQualityReportPayload, + AirQualityReportResponse, DiurnalData, MonthlyData, + DailyMeanData, SatelliteDataPayload, SatelliteDataResponse, + Grid, Site + + } from "./types" + + +const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN; +const BASE_URL = process.env.NEXT_PUBLIC_API_URL; +const PUBLIC_LOCATE_API_URL = process.env.NEXT_PUBLIC_LOCATE_API_URL; +const PUBLIC_SITE_CATEGORY_API_URL = process.env.NEXT_PUBLIC_SITE_CATEGORY_API_URL; +const PUBLIC_AIR_QUALITY_REPORT_API_URL_LLM = process.env.NEXT_PUBLIC_AIR_QUALITY_REPORT_API_URL_LLM; +const PUBLIC_SATELLITE_DATA_API_URL = process.env.NEXT_PUBLIC_SATELLITE_DATA_API_URL; +const PUBLIC_DEVICE_DATA_API_URL = process.env.NEXT_PUBLIC_DEVICE_DATA_API_URL; +const PUBLIC_GRID_SUMMARY_API_URL = process.env.NEXT_PUBLIC_GRID_SUMMARY_API_URL; + + +export async function submitLocations(payload: SiteLocatorPayload): Promise { + try { + if (!PUBLIC_LOCATE_API_URL || !API_TOKEN) { + throw new Error('API configuration missing'); + } + + console.log('Making API request to:', PUBLIC_LOCATE_API_URL); + console.log('Request payload:', payload); + + const response = await fetch(`${PUBLIC_LOCATE_API_URL}?token=${API_TOKEN}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.text(); + console.error('API Error Response:', errorData); + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.log('API Response data:', data); + return data; + } catch (error) { + console.error('Error submitting locations:', error); + throw error; + } + } + + export async function getSiteCategory(latitude: number, longitude: number): Promise { + try { + if (!PUBLIC_SITE_CATEGORY_API_URL || !API_TOKEN) { + throw new Error('API configuration missing'); + } + + console.log('Making site category API request for:', { latitude, longitude }); + + const response = await fetch( + `${PUBLIC_SITE_CATEGORY_API_URL}?latitude=${latitude}&longitude=${longitude}&token=${API_TOKEN}` + ); + + if (!response.ok) { + const errorData = await response.text(); + console.error('API Error Response:', errorData); + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.log('Site category API Response:', data); + return data; + } catch (error) { + console.error('Error getting site category:', error); + throw error; + } + } + + export async function getAirQualityReport(payload: AirQualityReportPayload): Promise { + try { + const response = await fetch(`${PUBLIC_AIR_QUALITY_REPORT_API_URL_LLM}?token=${API_TOKEN}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.text(); + console.error('API Error Response:', errorData); + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.log('Air Quality Report Response:', data); + return data; + } catch (error) { + console.error('Error getting air quality report:', error); + throw error; + } + } + + + export async function fetchGrids(): Promise { + const response = await fetch(`${PUBLIC_GRID_SUMMARY_API_URL}`); + if (!response.ok) { + throw new Error('Failed to fetch grids'); + } + const data = await response.json(); + return data.grids; + } \ No newline at end of file diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..5dc78c8 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,146 @@ +export interface Location { + lat: number; + lng: number; + } + + export interface SiteLocatorPayload { + polygon: { + coordinates: number[][][]; // 3D array for the polygon coordinates + }; + must_have_locations: number[][]; // Array of must-have locations (latitude, longitude) + min_distance_km: number; // Minimum distance for placement in kilometers + num_sensors: number; // Number of sensors to deploy + } + + export interface SiteInformation { + category_counts: { + [key: string]: number; // Counts of categories + }; + total_sites: number; // Total number of sites + } + + export interface SiteLocation { + area_name: string; // Name of the area + category: string; // Category of the site + highway: string | null; // Highway information, if any + landuse: string | null; // Land use type + latitude: number; // Latitude of the site + longitude: number; // Longitude of the site + natural: string | null; // Natural feature info, if any + } + + export interface SiteLocatorResponse { + site_information: SiteInformation; // Information about the sites + site_location: SiteLocation[]; // Array of site locations + } + + export interface ControlPanelProps { + onSubmit: (data: SiteLocatorPayload) => void; + polygon: Location[]; // Polygon points defining an area + mustHaveLocations: Location[]; // Must-have locations for site placement + onMustHaveLocationsChange: (locations: Location[]) => void; // Callback for changes in must-have locations + } + + export interface SiteCategoryResponse { + site: { + OSM_info: string[]; // OpenStreetMap info + 'site-category': { + area_name: string; + category: string; + highway: string; + landuse: string; + latitude: number; + longitude: number; + natural: string; + search_radius: number; + waterway: string; + }; + }; + } + + export interface GridOption { + grid_id: string; // Grid identifier + grid_name: string; // Name of the grid + } + + export interface AirQualityReportPayload { + grid_id: string; // Grid identifier + start_time: string; // Start time for the report + end_time: string; // End time for the report + } + + export interface DailyMeanData { + date: string; // Date of the data + pm10_calibrated_value: number | null; // Calibrated PM10 value + pm10_raw_value: number; // Raw PM10 value + pm2_5_calibrated_value: number | null; // Calibrated PM2.5 value + pm2_5_raw_value: number; // Raw PM2.5 value + } + + export interface DiurnalData { + hour: number; // Hour of the day + pm10_calibrated_value: number; // Calibrated PM10 value + pm10_raw_value: number; // Raw PM10 value + pm2_5_calibrated_value: number; // Calibrated PM2.5 value + pm2_5_raw_value: number; // Raw PM2.5 value + } + + export interface MonthlyData { + month: number; // Month number + year: number; // Year + pm10_calibrated_value: number; // Calibrated PM10 value + pm10_raw_value: number; // Raw PM10 value + pm2_5_calibrated_value: number; // Calibrated PM2.5 value + pm2_5_raw_value: number; // Raw PM2.5 value + site_latitude: number; // Latitude of the site + site_longitude: number; // Longitude of the site + site_name: string; // Name of the site + } + + export interface AirQualityReportResponse { + report: { + daily_mean_data: DailyMeanData[]; // Daily mean data for the report + diurnal: DiurnalData[]; // Diurnal data for the report + monthly_data: MonthlyData[]; // Monthly data for the report + report: string; // Textual report + }; + } + + export interface SatelliteDataPayload { + latitude: number; // Latitude for satellite data + longitude: number; // Longitude for satellite data + timestamp: string; // Timestamp for satellite data + } + + export interface SatelliteDataResponse { + latitude: number; // Latitude + longitude: number; // Longitude + pm2_5_prediction: number; // PM2.5 prediction value + timestamp: string; // Timestamp + } + + export interface Grid { + _id: string; // Grid ID + name: string; // Grid name + admin_level: string; // Administrative level of the grid + network: string; // Network type + long_name: string; // Long name for the grid + sites: Site[]; // List of sites in the grid + } + + export interface Site { + _id: string; // Site ID + search_name: string; // Searchable name for the site + city: string; // City where the site is located + district: string; // District of the site + county: string; // County of the site + region: string; // Region of the site + country: string; // Country of the site + name: string; // Site name + site_category: { + category: string; // Category of the site + }; + groups: string[]; // Groups associated with the site + lastActive: string; // Last active timestamp + } + \ No newline at end of file diff --git a/frontend/src/ui/input.tsx b/frontend/src/ui/input.tsx new file mode 100644 index 0000000..68551b9 --- /dev/null +++ b/frontend/src/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/frontend/src/ui/popover.tsx b/frontend/src/ui/popover.tsx new file mode 100644 index 0000000..bbba7e0 --- /dev/null +++ b/frontend/src/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/frontend/src/ui/toast.tsx b/frontend/src/ui/toast.tsx new file mode 100644 index 0000000..a822477 --- /dev/null +++ b/frontend/src/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/frontend/src/ui/use-toast.ts b/frontend/src/ui/use-toast.ts new file mode 100644 index 0000000..2bf2cf5 --- /dev/null +++ b/frontend/src/ui/use-toast.ts @@ -0,0 +1,3 @@ +import { useToast, toast } from "@components/hooks/use-toast"; + +export { useToast, toast }; From 617208575cf5f271ca14f1557c899e6d75ee2f8f Mon Sep 17 00:00:00 2001 From: wabinyai Date: Fri, 14 Feb 2025 15:47:36 +0300 Subject: [PATCH 06/55] control --- frontend/src/components/Controls/ControlPanel.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Controls/ControlPanel.tsx b/frontend/src/components/Controls/ControlPanel.tsx index 21255f2..5151e30 100644 --- a/frontend/src/components/Controls/ControlPanel.tsx +++ b/frontend/src/components/Controls/ControlPanel.tsx @@ -51,17 +51,22 @@ export function ControlPanel({ const handleSubmit = async () => { if (!validateInputs()) return; - const payload = { + const payload: any = { polygon: { coordinates: [ [...polygon.map((loc) => [loc.lng, loc.lat]), [polygon[0].lng, polygon[0].lat]], // Close the polygon ], }, must_have_locations: mustHaveLocations.length > 0 ? mustHaveLocations.map((loc) => [loc.lat, loc.lng]) : [], - min_distance_km: parseFloat(minDistance) || undefined, // Optional field num_sensors: parseInt(numSensors, 10), }; + // Ensure min_distance_km is only included if valid + const minDistanceValue = parseFloat(minDistance); + if (!isNaN(minDistanceValue)) { + payload.min_distance_km = minDistanceValue; + } + setIsLoading(true); try { await onSubmit(payload); @@ -153,7 +158,7 @@ export function ControlPanel({ />
- {/*Add Number of Sensors */} + {/* Number of Sensors */}
); -} \ No newline at end of file +} From f5b5fab833f79c25b4883d464d868eaf6c466e1c Mon Sep 17 00:00:00 2001 From: wabinyai Date: Fri, 14 Feb 2025 17:31:30 +0300 Subject: [PATCH 07/55] filesize --- .../src/components/Controls/FileUpload.tsx | 71 +++++++++++-------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/Controls/FileUpload.tsx b/frontend/src/components/Controls/FileUpload.tsx index 4cdc2e4..dcfd177 100644 --- a/frontend/src/components/Controls/FileUpload.tsx +++ b/frontend/src/components/Controls/FileUpload.tsx @@ -1,6 +1,4 @@ -"use client" - -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { Button } from '@/ui/button'; import { Upload } from 'lucide-react'; import Papa from 'papaparse'; @@ -18,36 +16,35 @@ interface FileUploadProps { export function FileUpload({ onUpload }: FileUploadProps) { const fileInputRef = useRef(null); const { toast } = useToast(); + const [isLoading, setIsLoading] = useState(false); + + const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + const ACCEPTED_FILE_TYPE = 'text/csv'; const normalizeColumnName = (name: string): string => name.trim().toLowerCase().replace(/\s+/g, ''); - const latitudeAliases = ['lat', 'latitude']; - const longitudeAliases = ['lng', 'lon', 'longitude']; + const latitudeAliases = new Set(['lat', 'latitude']); + const longitudeAliases = new Set(['lng', 'lon', 'longitude']); - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; + const parseCSV = (file: File) => { + setIsLoading(true); Papa.parse(file, { header: true, + skipEmptyLines: true, complete: (results) => { + setIsLoading(false); try { const headers = results.meta.fields || []; - if (!headers.length) { - throw new Error('CSV file has no headers.'); - } + if (!headers.length) throw new Error('CSV file has no headers.'); const normalizedHeaders = headers.map(normalizeColumnName); - const latIndex = normalizedHeaders.findIndex((header) => - latitudeAliases.includes(header) - ); - const lngIndex = normalizedHeaders.findIndex((header) => - longitudeAliases.includes(header) - ); + const latIndex = normalizedHeaders.findIndex((h) => latitudeAliases.has(h)); + const lngIndex = normalizedHeaders.findIndex((h) => longitudeAliases.has(h)); if (latIndex === -1 || lngIndex === -1) { - throw new Error('Could not find latitude and/or longitude columns in the CSV file.'); + throw new Error('Missing latitude and/or longitude columns.'); } const locations = results.data @@ -55,36 +52,46 @@ export function FileUpload({ onUpload }: FileUploadProps) { lat: parseFloat(row[headers[latIndex]]), lng: parseFloat(row[headers[lngIndex]]), })) - .filter((loc: Location) => !isNaN(loc.lat) && !isNaN(loc.lng)); + .filter(({ lat, lng }) => !isNaN(lat) && !isNaN(lng)); if (locations.length === 0) { throw new Error('No valid latitude/longitude pairs found.'); } onUpload(locations); - - toast({ - title: 'Success', - description: `Imported ${locations.length} locations from CSV`, - }); + toast({ title: 'Success', description: `Imported ${locations.length} locations.` }); } catch (error) { toast({ title: 'Error', - description: error instanceof Error ? error.message : 'Failed to parse CSV file.', + description: error instanceof Error ? error.message : 'Failed to parse CSV.', variant: 'destructive', }); } }, error: () => { - toast({ - title: 'Error', - description: 'Failed to read CSV file', - variant: 'destructive', - }); + setIsLoading(false); + toast({ title: 'Error', description: 'Failed to read CSV file.', variant: 'destructive' }); }, }); }; + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (file.size > MAX_FILE_SIZE) { + toast({ title: 'Error', description: 'File size exceeds 5MB limit.', variant: 'destructive' }); + return; + } + + if (file.type !== ACCEPTED_FILE_TYPE) { + toast({ title: 'Error', description: 'Please upload a valid CSV file.', variant: 'destructive' }); + return; + } + + parseCSV(file); + }; + return (
fileInputRef.current?.click()} + disabled={isLoading} + aria-label="Upload CSV file" > - Upload CSV + {isLoading ? 'Uploading...' : 'Upload CSV'}
); From f57818abc31bd3011ad38473c6164d9582915331 Mon Sep 17 00:00:00 2001 From: wabinyai Date: Fri, 14 Feb 2025 17:44:30 +0300 Subject: [PATCH 08/55] api --- frontend/src/lib/api.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7fe15f0..a7d5d14 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -19,6 +19,17 @@ const PUBLIC_SATELLITE_DATA_API_URL = process.env.NEXT_PUBLIC_SATELLITE_DATA_API const PUBLIC_DEVICE_DATA_API_URL = process.env.NEXT_PUBLIC_DEVICE_DATA_API_URL; const PUBLIC_GRID_SUMMARY_API_URL = process.env.NEXT_PUBLIC_GRID_SUMMARY_API_URL; +const requiredEnvVars = { + API_TOKEN: process.env.NEXT_PUBLIC_API_TOKEN, + BASE_URL: process.env.NEXT_PUBLIC_API_URL, + LOCATE_API_URL: process.env.NEXT_PUBLIC_LOCATE_API_URL, + // ... add other required variables as needed +}; + +Object.entries(requiredEnvVars).forEach(([key, value]) => { + if (!value) throw new Error(`Missing required environment variable: ${key}`); +}); + export async function submitLocations(payload: SiteLocatorPayload): Promise { try { @@ -107,10 +118,15 @@ export async function submitLocations(payload: SiteLocatorPayload): Promise { - const response = await fetch(`${PUBLIC_GRID_SUMMARY_API_URL}`); - if (!response.ok) { - throw new Error('Failed to fetch grids'); + try { + const response = await fetch(`${PUBLIC_GRID_SUMMARY_API_URL}?token=${API_TOKEN}`); + if (!response.ok) { + throw new Error(`Failed to fetch grids: ${response.status} ${response.statusText}`); + } + const data = await response.json(); + return data.grids; + } catch (error) { + console.error('Error fetching grids:', error); + throw new Error('Failed to fetch grids: ' + (error instanceof Error ? error.message : 'Unknown error')); } - const data = await response.json(); - return data.grids; } \ No newline at end of file From c20d4efcc1fd0e171d2d36ea26548591e00a9c76 Mon Sep 17 00:00:00 2001 From: wabinyai Date: Fri, 14 Feb 2025 18:29:19 +0300 Subject: [PATCH 09/55] aps --- frontend/src/app/locate/page.tsx | 51 ++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/locate/page.tsx b/frontend/src/app/locate/page.tsx index 7c14eb3..118b918 100644 --- a/frontend/src/app/locate/page.tsx +++ b/frontend/src/app/locate/page.tsx @@ -1,7 +1,7 @@ -"use client" +'use client'; // Add this at the top of the file import { useState } from 'react'; -import { MapComponent } from '@components/map/MapComponent'; +import { MapComponent } from '@/components/map/MapComponent'; import { ControlPanel } from '@/components/Controls/ControlPanel'; import { Location, SiteLocatorPayload } from '@/lib/types'; import { submitLocations } from '@/lib/api'; @@ -108,7 +108,7 @@ export default function Index() { return (
-
+
- -
- -
+ + {/* Draw Polygon Button */} +
+ +
- ); + ) } + From 04166704a5c6cceca6afdfec6739598b9f6f26c0 Mon Sep 17 00:00:00 2001 From: wabinyai Date: Sat, 15 Feb 2025 13:58:30 +0300 Subject: [PATCH 10/55] about --- frontend/src/components/navigation/navigation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/navigation/navigation.tsx b/frontend/src/components/navigation/navigation.tsx index 42169aa..6b27429 100644 --- a/frontend/src/components/navigation/navigation.tsx +++ b/frontend/src/components/navigation/navigation.tsx @@ -6,11 +6,11 @@ import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" const navItems = [ - { name: "Home", href: "/" }, - { name: "About", href: "/about" }, + { name: "Home", href: "/" }, { name: "Locate", href: "/locate" }, { name: "Categorize", href: "/categorize" }, { name: "Reports", href: "/reports" }, + { name: "About", href: "/about" }, ] export default function Navigation({ className, ...props }: React.HTMLAttributes) { From 0ac26a75bbedfbc21e6a13bc5ded333c944daa6a Mon Sep 17 00:00:00 2001 From: wabinyai Date: Sat, 15 Feb 2025 14:25:41 +0300 Subject: [PATCH 11/55] json package --- frontend/package-lock.json | 11 ++++++++++- frontend/package.json | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a8cb8f2..dd85e03 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "date-fns": "^4.1.0", "html2canvas": "^1.4.1", "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "leaflet-geosearch": "^4.0.0", "lucide-react": "^0.475.0", "next": "15.0.2", @@ -4493,7 +4494,14 @@ "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", - "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet-draw": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/leaflet-draw/-/leaflet-draw-1.0.4.tgz", + "integrity": "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==", + "license": "MIT" }, "node_modules/leaflet-geosearch": { "version": "4.0.0", @@ -5521,6 +5529,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", "dependencies": { "@react-leaflet/core": "^2.1.0" }, diff --git a/frontend/package.json b/frontend/package.json index 8e03eed..4a8fc8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "date-fns": "^4.1.0", "html2canvas": "^1.4.1", "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "leaflet-geosearch": "^4.0.0", "lucide-react": "^0.475.0", "next": "15.0.2", From 5a2c2de95f991bcb93e5783027df309d62483aa1 Mon Sep 17 00:00:00 2001 From: wabinyai Date: Sat, 15 Feb 2025 15:05:18 +0300 Subject: [PATCH 12/55] drawpolgo --- frontend/src/app/locate/page.tsx | 163 +++++++++--------- .../src/components/Controls/ControlPanel.tsx | 2 + frontend/src/components/map/MapComponent.tsx | 134 +++++++------- 3 files changed, 149 insertions(+), 150 deletions(-) diff --git a/frontend/src/app/locate/page.tsx b/frontend/src/app/locate/page.tsx index 118b918..8d6d730 100644 --- a/frontend/src/app/locate/page.tsx +++ b/frontend/src/app/locate/page.tsx @@ -1,109 +1,111 @@ -'use client'; // Add this at the top of the file - -import { useState } from 'react'; -import { MapComponent } from '@/components/map/MapComponent'; -import { ControlPanel } from '@/components/Controls/ControlPanel'; -import { Location, SiteLocatorPayload } from '@/lib/types'; -import { submitLocations } from '@/lib/api'; -import { useToast } from '@/ui/use-toast'; -import { Button } from '@/ui/button'; -import { Download, Camera } from 'lucide-react'; -import html2canvas from 'html2canvas'; -import Navigation from "@/components/navigation/navigation"; +"use client" // Add this at the top of the file + +import { useState } from "react" +import { MapComponent } from "@/components/map/MapComponent" +import { ControlPanel } from "@/components/Controls/ControlPanel" +import type { Location, SiteLocatorPayload } from "@/lib/types" +import { submitLocations } from "@/lib/api" +import { useToast } from "@/ui/use-toast" +import { Button } from "@/ui/button" +import { Download, Camera } from "lucide-react" +import html2canvas from "html2canvas" +import Navigation from "@/components/navigation/navigation" export default function Index() { - const [polygon, setPolygon] = useState([]); - const [mustHaveLocations, setMustHaveLocations] = useState([]); - const [suggestedLocations, setSuggestedLocations] = useState([]); - const { toast } = useToast(); + const [polygon, setPolygon] = useState([]) + const [mustHaveLocations, setMustHaveLocations] = useState([]) + const [suggestedLocations, setSuggestedLocations] = useState([]) + const { toast } = useToast() + const [isDrawing, setIsDrawing] = useState(false) const handleSubmit = async (payload: SiteLocatorPayload) => { try { - console.log('Submitting payload:', payload); // Debug log for request - const response = await submitLocations(payload); - console.log('API Response:', response); // Debug log for response + console.log("Submitting payload:", payload) // Debug log for request + const response = await submitLocations(payload) + console.log("API Response:", response) // Debug log for response if (!response.site_location || !Array.isArray(response.site_location)) { - throw new Error('Invalid response format from API'); + throw new Error("Invalid response format from API") } - - const locations = response.site_location.map(site => ({ + + const locations = response.site_location.map((site) => ({ lat: site.latitude, lng: site.longitude, - })); - - console.log('Processed locations to plot:', locations); // Debug log for processed locations - setSuggestedLocations(locations); - + })) + + console.log("Processed locations to plot:", locations) // Debug log for processed locations + setSuggestedLocations(locations) + toast({ - title: 'Success', + title: "Success", description: `Found ${locations.length} suggested locations`, - }); + }) } catch (error) { - console.error('Submit error:', error); + console.error("Submit error:", error) toast({ - title: 'Error', - description: error instanceof Error ? error.message : 'Failed to submit locations', - variant: 'destructive', - }); + title: "Error", + description: error instanceof Error ? error.message : "Failed to submit locations", + variant: "destructive", + }) } - }; + } const handleLocationClick = (location: Location) => { - setMustHaveLocations([...mustHaveLocations, location]); - }; + setMustHaveLocations([...mustHaveLocations, location]) + } const handleExportCSV = () => { if (suggestedLocations.length === 0) { toast({ - title: 'No Data', - description: 'No locations available to export', - variant: 'destructive', - }); - return; + title: "No Data", + description: "No locations available to export", + variant: "destructive", + }) + return } - const headers = ['Type', 'Latitude', 'Longitude', 'Area Name', 'Category']; + const headers = ["Type", "Latitude", "Longitude", "Area Name", "Category"] const rows = [ - ...mustHaveLocations.map(loc => ['Must Have', loc.lat, loc.lng, '', '']), - ...suggestedLocations.map(loc => ['Suggested', loc.lat, loc.lng, '', '']), - ]; - - const csvContent = [ - headers.join(','), - ...rows.map(row => row.join(',')), - ].join('\n'); - - const blob = new Blob([csvContent], { type: 'text/csv' }); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'locations.csv'; - a.click(); - window.URL.revokeObjectURL(url); - + ...mustHaveLocations.map((loc) => ["Must Have", loc.lat, loc.lng, "", ""]), + ...suggestedLocations.map((loc) => ["Suggested", loc.lat, loc.lng, "", ""]), + ] + + const csvContent = [headers.join(","), ...rows.map((row) => row.join(","))].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv" }) + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = "locations.csv" + a.click() + window.URL.revokeObjectURL(url) + toast({ - title: 'Success', - description: 'CSV file downloaded successfully', - }); - }; + title: "Success", + description: "CSV file downloaded successfully", + }) + } const handleSaveMap = async () => { - const mapElement = document.querySelector('.leaflet-container'); + const mapElement = document.querySelector(".leaflet-container") if (mapElement) { - const canvas = await html2canvas(mapElement as HTMLElement); - const url = canvas.toDataURL('image/png'); - const a = document.createElement('a'); - a.href = url; - a.download = 'map.png'; - a.click(); - + const canvas = await html2canvas(mapElement as HTMLElement) + const url = canvas.toDataURL("image/png") + const a = document.createElement("a") + a.href = url + a.download = "map.png" + a.click() + toast({ - title: 'Success', - description: 'Map image saved successfully', - }); + title: "Success", + description: "Map image saved successfully", + }) } - }; + } + + const toggleDrawing = () => { + setIsDrawing(!isDrawing) + } return (
@@ -115,6 +117,7 @@ export default function Index() { suggestedLocations={suggestedLocations} onPolygonChange={setPolygon} onLocationClick={handleLocationClick} + isDrawing={isDrawing} /> {/* Control Panel */} @@ -143,12 +146,10 @@ export default function Index() { {/* Draw Polygon Button */}
diff --git a/frontend/src/components/Controls/ControlPanel.tsx b/frontend/src/components/Controls/ControlPanel.tsx index 5151e30..52e87eb 100644 --- a/frontend/src/components/Controls/ControlPanel.tsx +++ b/frontend/src/components/Controls/ControlPanel.tsx @@ -1,3 +1,5 @@ +"use client" + import { useState } from 'react'; import { Button } from '@/ui/button'; import { Input } from '@/ui/input'; diff --git a/frontend/src/components/map/MapComponent.tsx b/frontend/src/components/map/MapComponent.tsx index 34e5296..c326f09 100644 --- a/frontend/src/components/map/MapComponent.tsx +++ b/frontend/src/components/map/MapComponent.tsx @@ -1,78 +1,80 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { MapContainer, TileLayer, useMap, Marker, Polygon } from 'react-leaflet'; -import L from 'leaflet'; -import 'leaflet/dist/leaflet.css'; -import { Location } from '@/lib/types'; -import { NavigationControls } from './NavigationControls'; -import { Button } from '@/ui/button'; -import { Map as MapIcon } from 'lucide-react'; -import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; +"use client" + +import { useEffect, useRef, useState } from "react" +import { MapContainer, TileLayer, useMap, Marker, Polygon } from "react-leaflet" +import L from "leaflet" +import "leaflet/dist/leaflet.css" +import type { Location } from "@/lib/types" +import { NavigationControls } from "./NavigationControls" +import { Button } from "@/ui/button" +import { MapIcon } from "lucide-react" +import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover" // Fix for default markers -delete (L.Icon.Default.prototype as any)._getIconUrl; +delete (L.Icon.Default.prototype as any)._getIconUrl // Create custom icons for different marker types const blueIcon = new L.Icon({ - iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png', + iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', + shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", shadowSize: [41, 41], -}); +}) const greenIcon = new L.Icon({ - iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png', + iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', + shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", shadowSize: [41, 41], -}); +}) const mapStyles = { streets: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", satellite: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", -}; +} -type MapStyle = keyof typeof mapStyles; +type MapStyle = keyof typeof mapStyles interface MapComponentProps { - polygon: Location[]; - mustHaveLocations: Location[]; - suggestedLocations: Location[]; - onPolygonChange: (locations: Location[]) => void; - onLocationClick: (location: Location) => void; + polygon: Location[] + mustHaveLocations: Location[] + suggestedLocations: Location[] + onPolygonChange: (locations: Location[]) => void + onLocationClick: (location: Location) => void + isDrawing: boolean } // Add map instance to window for global access declare global { interface Window { - map: L.Map; + map: L.Map } } function MapStyleControl() { - const map = useMap(); - const [currentStyle, setCurrentStyle] = useState('streets'); + const map = useMap() + const [currentStyle, setCurrentStyle] = useState("streets") const changeStyle = (style: MapStyle) => { - setCurrentStyle(style); + setCurrentStyle(style) // Find and remove the existing tile layer map.eachLayer((layer) => { if (layer instanceof L.TileLayer) { - map.removeLayer(layer); + map.removeLayer(layer) } - }); + }) // Add the new tile layer L.tileLayer(mapStyles[style], { - attribution: style === 'satellite' - ? '© ESRI' - : '© OpenStreetMap contributors' - }).addTo(map); - }; + attribution: + style === "satellite" + ? '© ESRI' + : '© OpenStreetMap contributors', + }).addTo(map) + } return (
@@ -96,48 +98,48 @@ function MapStyleControl() {
- ); + ) } function MapController() { - const map = useMap(); + const map = useMap() useEffect(() => { - window.map = map; - }, [map]); - return null; + window.map = map + }, [map]) + return null } function DrawControl({ onPolygonChange, }: { - onPolygonChange: (locations: Location[]) => void; + onPolygonChange: (locations: Location[]) => void }) { - const map = useMap(); - const drawingRef = useRef(); - const locationsRef = useRef([]); + const map = useMap() + const drawingRef = useRef() + const locationsRef = useRef([]) useEffect(() => { const handleClick = (e: L.LeafletMouseEvent) => { - const newLocation = { lat: e.latlng.lat, lng: e.latlng.lng }; - locationsRef.current = [...locationsRef.current, newLocation]; + const newLocation = { lat: e.latlng.lat, lng: e.latlng.lng } + locationsRef.current = [...locationsRef.current, newLocation] if (!drawingRef.current) { - drawingRef.current = L.polyline([], { color: 'blue' }).addTo(map); + drawingRef.current = L.polyline([], { color: "blue" }).addTo(map) } - drawingRef.current.setLatLngs(locationsRef.current); - onPolygonChange(locationsRef.current); - }; + drawingRef.current.setLatLngs(locationsRef.current) + onPolygonChange(locationsRef.current) + } - map.on('click', handleClick); + map.on("click", handleClick) return () => { - map.off('click', handleClick); - drawingRef.current?.remove(); - }; - }, [map, onPolygonChange]); + map.off("click", handleClick) + drawingRef.current?.remove() + } + }, [map, onPolygonChange]) - return null; + return null } export function MapComponent({ @@ -146,9 +148,8 @@ export function MapComponent({ suggestedLocations, onPolygonChange, onLocationClick, + isDrawing, }: MapComponentProps) { - const [isDrawing, setIsDrawing] = useState(false); - return (
} {polygon.length > 2 && ( - [loc.lat, loc.lng])} - pathOptions={{ color: 'blue' }} - /> + [loc.lat, loc.lng])} pathOptions={{ color: "blue" }} /> )} {mustHaveLocations.map((location, index) => ( @@ -188,11 +186,9 @@ export function MapComponent({ /> ))} - - setIsDrawing(!isDrawing)} - /> + + {}} />
- ); + ) } + From 339007ed1d51867b5d440f8909f9d107c3321d85 Mon Sep 17 00:00:00 2001 From: wabinyai Date: Sat, 15 Feb 2025 15:10:53 +0300 Subject: [PATCH 13/55] loading --- frontend/src/components/Controls/ControlPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Controls/ControlPanel.tsx b/frontend/src/components/Controls/ControlPanel.tsx index 52e87eb..2bcbf9b 100644 --- a/frontend/src/components/Controls/ControlPanel.tsx +++ b/frontend/src/components/Controls/ControlPanel.tsx @@ -175,7 +175,7 @@ export function ControlPanel({ {/* Submit Button */}
) } - diff --git a/frontend/src/components/Controls/ControlPanel.tsx b/frontend/src/components/Controls/ControlPanel.tsx index 2bcbf9b..be51465 100644 --- a/frontend/src/components/Controls/ControlPanel.tsx +++ b/frontend/src/components/Controls/ControlPanel.tsx @@ -1,17 +1,17 @@ "use client" -import { useState } from 'react'; -import { Button } from '@/ui/button'; -import { Input } from '@/ui/input'; -import { SearchBar } from './SearchBar'; -import { FileUpload } from './FileUpload'; -import { Location, ControlPanelProps } from '@/lib/types'; -import { useToast } from '@/ui/use-toast'; -import { Loader2 } from 'lucide-react'; +import { useState } from "react" +import { Button } from "@/ui/button" +import { Input } from "@/ui/input" +import { SearchBar } from "./SearchBar" +import { FileUpload } from "./FileUpload" +import type { Location, ControlPanelProps } from "@/lib/types" +import { useToast } from "@/ui/use-toast" +import { Loader2 } from "lucide-react" // Extend ControlPanelProps to include onBoundaryFound interface ExtendedControlPanelProps extends ControlPanelProps { - onBoundaryFound: (boundary: Location[]) => void; + onBoundaryFound: (boundary: Location[]) => void } export function ControlPanel({ @@ -21,37 +21,37 @@ export function ControlPanel({ onMustHaveLocationsChange, onBoundaryFound, }: ExtendedControlPanelProps) { - const [minDistance, setMinDistance] = useState('0.5'); // Default value for min_distance_km - const [numSensors, setNumSensors] = useState('5'); // Default value for num_sensors - const [newLat, setNewLat] = useState(''); - const [newLng, setNewLng] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const { toast } = useToast(); + const [minDistance, setMinDistance] = useState("0.5") // Default value for min_distance_km + const [numSensors, setNumSensors] = useState("5") // Default value for num_sensors + const [newLat, setNewLat] = useState("") + const [newLng, setNewLng] = useState("") + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() // Validation helper function const validateInputs = () => { if (polygon.length < 3) { toast({ - title: 'Error', - description: 'Please draw a polygon on the map', - variant: 'destructive', - }); - return false; + title: "Error", + description: "Please draw a polygon on the map", + variant: "destructive", + }) + return false } - if (!numSensors || parseInt(numSensors) < 1) { + if (!numSensors || Number.parseInt(numSensors) < 1) { toast({ - title: 'Error', - description: 'Please enter a valid number of sensors', - variant: 'destructive', - }); - return false; + title: "Error", + description: "Please enter a valid number of sensors", + variant: "destructive", + }) + return false } - return true; - }; + return true + } // Handle form submission const handleSubmit = async () => { - if (!validateInputs()) return; + if (!validateInputs()) return const payload: any = { polygon: { @@ -60,58 +60,58 @@ export function ControlPanel({ ], }, must_have_locations: mustHaveLocations.length > 0 ? mustHaveLocations.map((loc) => [loc.lat, loc.lng]) : [], - num_sensors: parseInt(numSensors, 10), - }; + num_sensors: Number.parseInt(numSensors, 10), + } // Ensure min_distance_km is only included if valid - const minDistanceValue = parseFloat(minDistance); + const minDistanceValue = Number.parseFloat(minDistance) if (!isNaN(minDistanceValue)) { - payload.min_distance_km = minDistanceValue; + payload.min_distance_km = minDistanceValue } - setIsLoading(true); + setIsLoading(true) try { - await onSubmit(payload); + await onSubmit(payload) toast({ - title: 'Success', - description: 'Locations submitted successfully', - }); + title: "Success", + description: "Locations submitted successfully", + }) } catch (error) { toast({ - title: 'Error', - description: 'Failed to submit locations', - variant: 'destructive', - }); + title: "Error", + description: "Failed to submit locations", + variant: "destructive", + }) } finally { - setIsLoading(false); + setIsLoading(false) } - }; + } // Add a new must-have location const handleAddLocation = () => { - const lat = parseFloat(newLat); - const lng = parseFloat(newLng); + const lat = Number.parseFloat(newLat) + const lng = Number.parseFloat(newLng) if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { toast({ - title: 'Error', - description: 'Please enter valid latitude (-90 to 90) and longitude (-180 to 180)', - variant: 'destructive', - }); - return; + title: "Error", + description: "Please enter valid latitude (-90 to 90) and longitude (-180 to 180)", + variant: "destructive", + }) + return } - onMustHaveLocationsChange([...mustHaveLocations, { lat, lng }]); - setNewLat(''); - setNewLng(''); + onMustHaveLocationsChange([...mustHaveLocations, { lat, lng }]) + setNewLat("") + setNewLng("") toast({ - title: 'Success', - description: 'Location added successfully', - }); - }; + title: "Success", + description: "Location added successfully", + }) + } return ( -
+

Air Quality Site Locator

{/* Search Bar */} @@ -121,14 +121,14 @@ export function ControlPanel({
- setNewLat(e.target.value)} step="any" aria-label="Latitude" - /> + />
-
- {mustHaveLocations.length} locations added -
+
{mustHaveLocations.length} locations added
{/* Minimum Distance */} @@ -181,14 +179,15 @@ export function ControlPanel({ aria-label="Submit" > {isLoading ? ( - <> +
- Submitting... - + Submitting... +
) : ( - 'Submit' + "Submit" )}
- ); + ) } + diff --git a/frontend/src/components/Controls/FileUpload.tsx b/frontend/src/components/Controls/FileUpload.tsx index dcfd177..f84fd18 100644 --- a/frontend/src/components/Controls/FileUpload.tsx +++ b/frontend/src/components/Controls/FileUpload.tsx @@ -3,60 +3,50 @@ import { Button } from '@/ui/button'; import { Upload } from 'lucide-react'; import Papa from 'papaparse'; import { useToast } from '@/ui/use-toast'; +import type {Location, FileUploadProps} from "@/lib/types" -interface Location { - lat: number; - lng: number; -} +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ACCEPTED_FILE_TYPE = 'text/csv'; -interface FileUploadProps { - onUpload: (locations: Location[]) => void; -} +const normalizeColumnName = (name: string): string => + name.trim().toLowerCase().replace(/\s+/g, ''); + +const latitudeAliases = new Set(['lat', 'latitude']); +const longitudeAliases = new Set(['lng', 'lon', 'longitude']); export function FileUpload({ onUpload }: FileUploadProps) { const fileInputRef = useRef(null); const { toast } = useToast(); const [isLoading, setIsLoading] = useState(false); - const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - const ACCEPTED_FILE_TYPE = 'text/csv'; - - const normalizeColumnName = (name: string): string => - name.trim().toLowerCase().replace(/\s+/g, ''); - - const latitudeAliases = new Set(['lat', 'latitude']); - const longitudeAliases = new Set(['lng', 'lon', 'longitude']); - const parseCSV = (file: File) => { setIsLoading(true); - Papa.parse(file, { + Papa.parse>(file, { header: true, skipEmptyLines: true, - complete: (results) => { + complete: ({ data, meta }) => { setIsLoading(false); + try { - const headers = results.meta.fields || []; + const headers = meta.fields?.map(normalizeColumnName) || []; if (!headers.length) throw new Error('CSV file has no headers.'); - const normalizedHeaders = headers.map(normalizeColumnName); - const latIndex = normalizedHeaders.findIndex((h) => latitudeAliases.has(h)); - const lngIndex = normalizedHeaders.findIndex((h) => longitudeAliases.has(h)); + const latIndex = headers.findIndex((h) => latitudeAliases.has(h)); + const lngIndex = headers.findIndex((h) => longitudeAliases.has(h)); if (latIndex === -1 || lngIndex === -1) { throw new Error('Missing latitude and/or longitude columns.'); } - const locations = results.data - .map((row: any) => ({ - lat: parseFloat(row[headers[latIndex]]), - lng: parseFloat(row[headers[lngIndex]]), + const locations: Location[] = data + .map((row) => ({ + lat: parseFloat(row[meta.fields![latIndex]] || ''), + lng: parseFloat(row[meta.fields![lngIndex]] || ''), })) .filter(({ lat, lng }) => !isNaN(lat) && !isNaN(lng)); - if (locations.length === 0) { - throw new Error('No valid latitude/longitude pairs found.'); - } + if (!locations.length) throw new Error('No valid latitude/longitude pairs found.'); onUpload(locations); toast({ title: 'Success', description: `Imported ${locations.length} locations.` }); @@ -94,16 +84,10 @@ export function FileUpload({ onUpload }: FileUploadProps) { return (
- +
) } + diff --git a/frontend/src/app/reports/page.tsx b/frontend/src/app/reports/page.tsx new file mode 100644 index 0000000..d01ec76 --- /dev/null +++ b/frontend/src/app/reports/page.tsx @@ -0,0 +1,90 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/ui/card"; +import { Sparkles, BarChart3, BrainCircuit } from "lucide-react"; +import Navigation from "@/components/navigation/navigation"; +import { + BarChart as ReBarChart, + LineChart as ReLineChart, + PieChart as RePieChart, + AreaChart as ReAreaChart, + Bar, + Line, + Pie, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from "recharts"; +import { ReactNode } from "react"; + +export default function ReportPage() { + return ( +
+ + +
+ ); +} + +function ReportContent() { + return ( +
+
+ + Coming Soon +
+

+ Our AI-powered air quality reports are on the way! Stay tuned for real-time insights and advanced analytics + to help you understand air pollution trends like never before. +

+ + + + + AI-Generated Air Quality Report + + + +

+ Leveraging artificial intelligence, we analyze real-time air pollution data to provide accurate insights + and actionable recommendations. Our reports cover PM2.5 trends, pollution hotspots, and seasonal variations. +

+
+ }> + Analyze pollution levels over time to detect patterns and anomalies. + + }> + Compare air quality across different locations with detailed breakdowns. + + }> + Understand how pollution affects respiratory health and well-being. + + }> + Smart predictions based on historical data to help communities prepare in advance. + +
+
+
+
+ ); +} + +interface InfoBoxProps { + title: string; + icon: ReactNode; + children: ReactNode; +} + +function InfoBox({ title, icon, children }: InfoBoxProps) { + return ( +
+ {icon} +
+

{title}

+

{children}

+
+
+ ); +} diff --git a/frontend/src/ui/card.tsx b/frontend/src/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/frontend/src/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } From 7a29d5cdf14fe63646cda4b58dba4055c452b242 Mon Sep 17 00:00:00 2001 From: wabinyai Date: Sun, 16 Feb 2025 00:05:08 +0300 Subject: [PATCH 16/55] trim --- frontend/src/components/Controls/SearchBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Controls/SearchBar.tsx b/frontend/src/components/Controls/SearchBar.tsx index 009016b..074be6e 100644 --- a/frontend/src/components/Controls/SearchBar.tsx +++ b/frontend/src/components/Controls/SearchBar.tsx @@ -109,7 +109,7 @@ export function SearchBar({ onSearch, onBoundaryFound }: SearchBarProps) { )} - From fa03ef5d778ede35f34c933ea4a55c036593721f Mon Sep 17 00:00:00 2001 From: wabinyai Date: Sun, 16 Feb 2025 14:39:22 +0300 Subject: [PATCH 17/55] found --- frontend/src/app/not-found.tsx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx index 2f561bc..4552e07 100644 --- a/frontend/src/app/not-found.tsx +++ b/frontend/src/app/not-found.tsx @@ -1,15 +1,10 @@ -"use client" +"use client"; -import { useEffect } from "react" -import { usePathname } from "next/navigation" -import Link from "next/link" +import { usePathname } from "next/navigation"; +import Link from "next/link"; const NotFound = () => { - const pathname = usePathname() - - useEffect(() => { - console.error("404 Error: User attempted to access non-existent route:", pathname) - }, [pathname]) + const pathname = usePathname(); return (
@@ -28,8 +23,7 @@ const NotFound = () => {
- ) -} - -export default NotFound + ); +}; +export default NotFound; From 7a7d3cdd3f20b738481e105bed1c1006b5ddf84e Mon Sep 17 00:00:00 2001 From: wabinyai Date: Sun, 16 Feb 2025 16:19:38 +0300 Subject: [PATCH 18/55] doubling --- frontend/src/app/locate/page.tsx | 26 +++++++++++-------- .../src/components/Controls/FileUpload.tsx | 26 +++++++++---------- frontend/src/components/map/MapComponent.tsx | 4 +-- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/locate/page.tsx b/frontend/src/app/locate/page.tsx index 9cd7de6..f60d813 100644 --- a/frontend/src/app/locate/page.tsx +++ b/frontend/src/app/locate/page.tsx @@ -69,17 +69,21 @@ export default function Index() { const uniqueLocations = new Set() const formatRow = (type: string, loc: Location) => `${type},${loc.lat},${loc.lng},,` - const rows = [ - ...mustHaveLocations.map((loc) => formatRow("Must Have", loc)), - ...suggestedLocations - .map((loc) => formatRow("Suggested", loc)) - .filter((row) => { - const key = `${row.split(",")[1]},${row.split(",")[2]}` - if (uniqueLocations.has(key)) return false - uniqueLocations.add(key) - return true - }), - ] + // Add must-have locations first + const rows = mustHaveLocations.map((loc) => { + const key = `${loc.lat},${loc.lng}` + uniqueLocations.add(key) + return formatRow("Must Have", loc) + }) + + // Add suggested locations, excluding duplicates and must-have locations + suggestedLocations.forEach((loc) => { + const key = `${loc.lat},${loc.lng}` + if (!uniqueLocations.has(key)) { + uniqueLocations.add(key) + rows.push(formatRow("Suggested", loc)) + } + }) const csvContent = [headers.join(","), ...rows].join("\n") diff --git a/frontend/src/components/Controls/FileUpload.tsx b/frontend/src/components/Controls/FileUpload.tsx index f84fd18..2908346 100644 --- a/frontend/src/components/Controls/FileUpload.tsx +++ b/frontend/src/components/Controls/FileUpload.tsx @@ -3,13 +3,13 @@ import { Button } from '@/ui/button'; import { Upload } from 'lucide-react'; import Papa from 'papaparse'; import { useToast } from '@/ui/use-toast'; -import type {Location, FileUploadProps} from "@/lib/types" +import type { Location, FileUploadProps } from '@/lib/types'; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_FILE_TYPE = 'text/csv'; const normalizeColumnName = (name: string): string => - name.trim().toLowerCase().replace(/\s+/g, ''); + name.trim().toLowerCase().replace(/[^a-z0-9]/g, ''); const latitudeAliases = new Set(['lat', 'latitude']); const longitudeAliases = new Set(['lng', 'lon', 'longitude']); @@ -25,6 +25,7 @@ export function FileUpload({ onUpload }: FileUploadProps) { Papa.parse>(file, { header: true, skipEmptyLines: true, + worker: true, // Enables Web Worker for performance complete: ({ data, meta }) => { setIsLoading(false); @@ -32,17 +33,21 @@ export function FileUpload({ onUpload }: FileUploadProps) { const headers = meta.fields?.map(normalizeColumnName) || []; if (!headers.length) throw new Error('CSV file has no headers.'); - const latIndex = headers.findIndex((h) => latitudeAliases.has(h)); - const lngIndex = headers.findIndex((h) => longitudeAliases.has(h)); + const latHeader = headers.find((h) => latitudeAliases.has(h)); + const lngHeader = headers.find((h) => longitudeAliases.has(h)); - if (latIndex === -1 || lngIndex === -1) { - throw new Error('Missing latitude and/or longitude columns.'); + if (!latHeader && !lngHeader) { + throw new Error('Missing both latitude and longitude columns.'); + } else if (!latHeader) { + throw new Error('Missing latitude column.'); + } else if (!lngHeader) { + throw new Error('Missing longitude column.'); } const locations: Location[] = data .map((row) => ({ - lat: parseFloat(row[meta.fields![latIndex]] || ''), - lng: parseFloat(row[meta.fields![lngIndex]] || ''), + lat: parseFloat(row[latHeader] || ''), + lng: parseFloat(row[lngHeader] || ''), })) .filter(({ lat, lng }) => !isNaN(lat) && !isNaN(lng)); @@ -74,11 +79,6 @@ export function FileUpload({ onUpload }: FileUploadProps) { return; } - if (file.type !== ACCEPTED_FILE_TYPE) { - toast({ title: 'Error', description: 'Please upload a valid CSV file.', variant: 'destructive' }); - return; - } - parseCSV(file); }; diff --git a/frontend/src/components/map/MapComponent.tsx b/frontend/src/components/map/MapComponent.tsx index c326f09..c4bb6f9 100644 --- a/frontend/src/components/map/MapComponent.tsx +++ b/frontend/src/components/map/MapComponent.tsx @@ -174,7 +174,7 @@ export function MapComponent({ ))} @@ -182,7 +182,7 @@ export function MapComponent({ ))} From 2de39bc4954950265800b444de5996ffcd6d99db Mon Sep 17 00:00:00 2001 From: wabinyai Date: Sun, 16 Feb 2025 17:19:48 +0300 Subject: [PATCH 19/55] air --- frontend/src/app/about/page.tsx | 6 +++--- frontend/src/components/navigation/navigation.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx index 05be004..366df49 100644 --- a/frontend/src/app/about/page.tsx +++ b/frontend/src/app/about/page.tsx @@ -50,10 +50,10 @@ export default function AboutPage() {

Our Impact

    -
  • Deployed over 100 low-cost air quality sensors across East Africa
  • -
  • Provided air quality data to millions of citizens through our mobile app and API
  • +
  • Deployed over 300 low-cost air quality sensors across Africa
  • +
  • Provided air quality data to millions of citizens through our digital platform and API
  • Collaborated with local governments to develop data-driven air quality management strategies
  • -
  • Engaged in capacity building, training over 500 individuals in air quality monitoring and analysis
  • +
  • Engaged in capacity building, training over 5000 individuals in air quality monitoring and analysis
diff --git a/frontend/src/components/navigation/navigation.tsx b/frontend/src/components/navigation/navigation.tsx index 6b27429..04fa935 100644 --- a/frontend/src/components/navigation/navigation.tsx +++ b/frontend/src/components/navigation/navigation.tsx @@ -6,7 +6,7 @@ import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" const navItems = [ - { name: "Home", href: "/" }, + { name: "Home", href: "/" }, { name: "Locate", href: "/locate" }, { name: "Categorize", href: "/categorize" }, { name: "Reports", href: "/reports" }, @@ -17,7 +17,7 @@ export default function Navigation({ className, ...props }: React.HTMLAttributes const pathname = usePathname() return ( -
+
AirQo AI From 7d8afe89ce77c168a85eb548239b34a4a96004b0 Mon Sep 17 00:00:00 2001 From: Ochieng Paul Date: Sun, 16 Feb 2025 18:26:43 +0300 Subject: [PATCH 20/55] Fix all Lint and type errors --- frontend/package-lock.json | 108 +++--- frontend/package.json | 2 +- frontend/src/app/about/page.tsx | 38 ++- frontend/src/app/locate/page.tsx | 155 +++++---- frontend/src/app/not-found.tsx | 15 +- frontend/src/app/reports/page.tsx | 59 ++-- .../src/components/Controls/ControlPanel.tsx | 126 ++++--- .../src/components/Controls/FileUpload.tsx | 72 ++-- .../src/components/Controls/SearchBar.tsx | 69 +++- frontend/src/components/hooks/use-toast.ts | 127 ++++--- frontend/src/components/map/LeafletMap.tsx | 317 ++++++++++-------- frontend/src/components/map/MapComponent.tsx | 141 ++++---- .../src/components/navigation/navigation.tsx | 23 +- frontend/src/lib/api.ts | 227 +++++++------ 14 files changed, 841 insertions(+), 638 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 361618a..47b2e47 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,7 +21,7 @@ "leaflet-draw": "^1.0.4", "leaflet-geosearch": "^4.0.0", "lucide-react": "^0.475.0", - "next": "15.0.2", + "next": "^15.1.7", "papaparse": "^5.5.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -650,9 +650,9 @@ } }, "node_modules/@next/env": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.2.tgz", - "integrity": "sha512-c0Zr0ModK5OX7D4ZV8Jt/wqoXtitLNPwUfG9zElCZztdaZyNVnN40rDXVZ/+FGuR4CcNV5AEfM6N8f+Ener7Dg==" + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.7.tgz", + "integrity": "sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ==" }, "node_modules/@next/eslint-plugin-next": { "version": "15.0.2", @@ -664,9 +664,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.2.tgz", - "integrity": "sha512-GK+8w88z+AFlmt+ondytZo2xpwlfAR8U6CRwXancHImh6EdGfHMIrTSCcx5sOSBei00GyLVL0ioo1JLKTfprgg==", + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.7.tgz", + "integrity": "sha512-hPFwzPJDpA8FGj7IKV3Yf1web3oz2YsR8du4amKw8d+jAOHfYHYFpMkoF6vgSY4W6vB29RtZEklK9ayinGiCmQ==", "cpu": [ "arm64" ], @@ -679,9 +679,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.2.tgz", - "integrity": "sha512-KUpBVxIbjzFiUZhiLIpJiBoelqzQtVZbdNNsehhUn36e2YzKHphnK8eTUW1s/4aPy5kH/UTid8IuVbaOpedhpw==", + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.7.tgz", + "integrity": "sha512-2qoas+fO3OQKkU0PBUfwTiw/EYpN+kdAx62cePRyY1LqKtP09Vp5UcUntfZYajop5fDFTjSxCHfZVRxzi+9FYQ==", "cpu": [ "x64" ], @@ -694,9 +694,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.2.tgz", - "integrity": "sha512-9J7TPEcHNAZvwxXRzOtiUvwtTD+fmuY0l7RErf8Yyc7kMpE47MIQakl+3jecmkhOoIyi/Rp+ddq7j4wG6JDskQ==", + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.7.tgz", + "integrity": "sha512-sKLLwDX709mPdzxMnRIXLIT9zaX2w0GUlkLYQnKGoXeWUhcvpCrK+yevcwCJPdTdxZEUA0mOXGLdPsGkudGdnA==", "cpu": [ "arm64" ], @@ -709,9 +709,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.2.tgz", - "integrity": "sha512-BjH4ZSzJIoTTZRh6rG+a/Ry4SW0HlizcPorqNBixBWc3wtQtj4Sn9FnRZe22QqrPnzoaW0ctvSz4FaH4eGKMww==", + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.7.tgz", + "integrity": "sha512-zblK1OQbQWdC8fxdX4fpsHDw+VSpBPGEUX4PhSE9hkaWPrWoeIJn+baX53vbsbDRaDKd7bBNcXRovY1hEhFd7w==", "cpu": [ "arm64" ], @@ -724,9 +724,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.0.2.tgz", - "integrity": "sha512-i3U2TcHgo26sIhcwX/Rshz6avM6nizrZPvrDVDY1bXcLH1ndjbO8zuC7RoHp0NSK7wjJMPYzm7NYL1ksSKFreA==", + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.7.tgz", + "integrity": "sha512-GOzXutxuLvLHFDAPsMP2zDBMl1vfUHHpdNpFGhxu90jEzH6nNIgmtw/s1MDwpTOiM+MT5V8+I1hmVFeAUhkbgQ==", "cpu": [ "x64" ], @@ -739,9 +739,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.0.2.tgz", - "integrity": "sha512-AMfZfSVOIR8fa+TXlAooByEF4OB00wqnms1sJ1v+iu8ivwvtPvnkwdzzFMpsK5jA2S9oNeeQ04egIWVb4QWmtQ==", + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.7.tgz", + "integrity": "sha512-WrZ7jBhR7ATW1z5iEQ0ZJfE2twCNSXbpCSaAunF3BKcVeHFADSI/AW1y5Xt3DzTqPF1FzQlwQTewqetAABhZRQ==", "cpu": [ "x64" ], @@ -754,9 +754,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.2.tgz", - "integrity": "sha512-JkXysDT0/hEY47O+Hvs8PbZAeiCQVxKfGtr4GUpNAhlG2E0Mkjibuo8ryGD29Qb5a3IOnKYNoZlh/MyKd2Nbww==", + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.7.tgz", + "integrity": "sha512-LDnj1f3OVbou1BqvvXVqouJZKcwq++mV2F+oFHptToZtScIEnhNRJAhJzqAtTE2dB31qDYL45xJwrc+bLeKM2Q==", "cpu": [ "arm64" ], @@ -769,9 +769,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.2.tgz", - "integrity": "sha512-foaUL0NqJY/dX0Pi/UcZm5zsmSk5MtP/gxx3xOPyREkMFN+CTjctPfu3QaqrQHinaKdPnMWPJDKt4VjDfTBe/Q==", + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.7.tgz", + "integrity": "sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ==", "cpu": [ "x64" ], @@ -1398,11 +1398,11 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, "node_modules/@swc/helpers": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", - "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@types/d3-array": { @@ -2458,9 +2458,9 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4964,9 +4964,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -4987,13 +4987,13 @@ "dev": true }, "node_modules/next": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.0.2.tgz", - "integrity": "sha512-rxIWHcAu4gGSDmwsELXacqAPUk+j8dV/A9cDF5fsiCMpkBDYkO2AEaL1dfD+nNmDiU6QMCFN8Q30VEKapT9UHQ==", + "version": "15.1.7", + "resolved": "https://registry.npmjs.org/next/-/next-15.1.7.tgz", + "integrity": "sha512-GNeINPGS9c6OZKCvKypbL8GTsT5GhWPp4DM0fzkXJuXMilOO2EeFxuAY6JZbtk6XIl6Ws10ag3xRINDjSO5+wg==", "dependencies": { - "@next/env": "15.0.2", + "@next/env": "15.1.7", "@swc/counter": "0.1.3", - "@swc/helpers": "0.5.13", + "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -5003,25 +5003,25 @@ "next": "dist/bin/next" }, "engines": { - "node": ">=18.18.0" + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.0.2", - "@next/swc-darwin-x64": "15.0.2", - "@next/swc-linux-arm64-gnu": "15.0.2", - "@next/swc-linux-arm64-musl": "15.0.2", - "@next/swc-linux-x64-gnu": "15.0.2", - "@next/swc-linux-x64-musl": "15.0.2", - "@next/swc-win32-arm64-msvc": "15.0.2", - "@next/swc-win32-x64-msvc": "15.0.2", + "@next/swc-darwin-arm64": "15.1.7", + "@next/swc-darwin-x64": "15.1.7", + "@next/swc-linux-arm64-gnu": "15.1.7", + "@next/swc-linux-arm64-musl": "15.1.7", + "@next/swc-linux-x64-gnu": "15.1.7", + "@next/swc-linux-x64-musl": "15.1.7", + "@next/swc-win32-arm64-msvc": "15.1.7", + "@next/swc-win32-x64-msvc": "15.1.7", "sharp": "^0.33.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-02c0e824-20241028", - "react-dom": "^18.2.0 || 19.0.0-rc-02c0e824-20241028", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "peerDependenciesMeta": { diff --git a/frontend/package.json b/frontend/package.json index 6b021fd..3675327 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,7 @@ "leaflet-draw": "^1.0.4", "leaflet-geosearch": "^4.0.0", "lucide-react": "^0.475.0", - "next": "15.0.2", + "next": "^15.1.7", "papaparse": "^5.5.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx index 366df49..9ee1df5 100644 --- a/frontend/src/app/about/page.tsx +++ b/frontend/src/app/about/page.tsx @@ -17,18 +17,23 @@ export default function AboutPage() {

- AirQo is a pioneering initiative dedicated to improving air quality monitoring and management across Africa. - Our mission is to provide accurate, actionable air quality information to empower communities, researchers, - and policymakers in the fight against air pollution. + AirQo is a pioneering initiative dedicated to improving air quality + monitoring and management across Africa. Our mission is to provide + accurate, actionable air quality information to empower communities, + researchers, and policymakers in the fight against air pollution.

- Founded in 2015 at Makerere University in Uganda, AirQo has grown into a multidisciplinary team of engineers, - data scientists, and environmental experts. We're committed to developing innovative, low-cost air quality - monitoring solutions tailored for the unique challenges of African urban environments. + Founded in 2015 at Makerere University in Uganda, AirQo has grown + into a multidisciplinary team of engineers, data scientists, and + environmental experts. We're committed to developing + innovative, low-cost air quality monitoring solutions tailored for + the unique challenges of African urban environments.

-

Our Core Values

+

+ Our Core Values +

Our Impact

    -
  • Deployed over 300 low-cost air quality sensors across Africa
  • -
  • Provided air quality data to millions of citizens through our digital platform and API
  • -
  • Collaborated with local governments to develop data-driven air quality management strategies
  • -
  • Engaged in capacity building, training over 5000 individuals in air quality monitoring and analysis
  • +
  • + Deployed over 300 low-cost air quality sensors across Africa +
  • +
  • + Provided air quality data to millions of citizens through our + digital platform and API +
  • +
  • + Collaborated with local governments to develop data-driven air + quality management strategies +
  • +
  • + Engaged in capacity building, training over 5000 individuals in + air quality monitoring and analysis +
diff --git a/frontend/src/app/locate/page.tsx b/frontend/src/app/locate/page.tsx index f60d813..8e4ce2b 100644 --- a/frontend/src/app/locate/page.tsx +++ b/frontend/src/app/locate/page.tsx @@ -1,58 +1,63 @@ -"use client" - -import { useState } from "react" -import { MapComponent } from "@/components/map/MapComponent" -import { ControlPanel } from "@/components/Controls/ControlPanel" -import type { Location, SiteLocatorPayload } from "@/lib/types" -import { submitLocations } from "@/lib/api" -import { useToast } from "@/ui/use-toast" -import { Button } from "@/ui/button" -import { Download, Camera } from "lucide-react" -import html2canvas from "html2canvas" -import Navigation from "@/components/navigation/navigation" +"use client"; + +import { useState } from "react"; +import { ControlPanel } from "@/components/Controls/ControlPanel"; +import type { Location, SiteLocatorPayload } from "@/lib/types"; +import { submitLocations } from "@/lib/api"; +import { useToast } from "@/ui/use-toast"; +import { Button } from "@/ui/button"; +import { Download, Camera } from "lucide-react"; +import html2canvas from "html2canvas"; +import Navigation from "@/components/navigation/navigation"; +import dynamic from "next/dynamic"; + +const MapComponent = dynamic(() => import("@/components/map/MapComponent"), { + ssr: false, +}); export default function Index() { - const [polygon, setPolygon] = useState([]) - const [mustHaveLocations, setMustHaveLocations] = useState([]) - const [suggestedLocations, setSuggestedLocations] = useState([]) - const { toast } = useToast() - const [isDrawing, setIsDrawing] = useState(false) + const [polygon, setPolygon] = useState([]); + const [mustHaveLocations, setMustHaveLocations] = useState([]); + const [suggestedLocations, setSuggestedLocations] = useState([]); + const { toast } = useToast(); + const [isDrawing, setIsDrawing] = useState(false); const handleSubmit = async (payload: SiteLocatorPayload) => { try { - console.log("Submitting payload:", payload) // Debug log for request - const response = await submitLocations(payload) - console.log("API Response:", response) // Debug log for response + console.log("Submitting payload:", payload); // Debug log for request + const response = await submitLocations(payload); + console.log("API Response:", response); // Debug log for response if (!response.site_location || !Array.isArray(response.site_location)) { - throw new Error("Invalid response format from API") + throw new Error("Invalid response format from API"); } const locations = response.site_location.map((site) => ({ lat: site.latitude, lng: site.longitude, - })) + })); - console.log("Processed locations to plot:", locations) // Debug log for processed locations - setSuggestedLocations(locations) + console.log("Processed locations to plot:", locations); // Debug log for processed locations + setSuggestedLocations(locations); toast({ title: "Success", description: `Found ${locations.length} suggested locations`, - }) + }); } catch (error) { - console.error("Submit error:", error) + console.error("Submit error:", error); toast({ title: "Error", - description: error instanceof Error ? error.message : "Failed to submit locations", + description: + error instanceof Error ? error.message : "Failed to submit locations", variant: "destructive", - }) + }); } - } + }; const handleLocationClick = (location: Location) => { - setMustHaveLocations([...mustHaveLocations, location]) - } + setMustHaveLocations([...mustHaveLocations, location]); + }; const handleExportCSV = () => { if (suggestedLocations.length === 0 && mustHaveLocations.length === 0) { @@ -60,67 +65,68 @@ export default function Index() { title: "No Data", description: "No locations available to export", variant: "destructive", - }) - return + }); + return; } - const headers = ["Type", "Latitude", "Longitude", "Area Name", "Category"] + const headers = ["Type", "Latitude", "Longitude", "Area Name", "Category"]; - const uniqueLocations = new Set() - const formatRow = (type: string, loc: Location) => `${type},${loc.lat},${loc.lng},,` + const uniqueLocations = new Set(); + const formatRow = (type: string, loc: Location) => + `${type},${loc.lat},${loc.lng},,`; // Add must-have locations first const rows = mustHaveLocations.map((loc) => { - const key = `${loc.lat},${loc.lng}` - uniqueLocations.add(key) - return formatRow("Must Have", loc) - }) + const key = `${loc.lat},${loc.lng}`; + uniqueLocations.add(key); + return formatRow("Must Have", loc); + }); // Add suggested locations, excluding duplicates and must-have locations suggestedLocations.forEach((loc) => { - const key = `${loc.lat},${loc.lng}` + const key = `${loc.lat},${loc.lng}`; if (!uniqueLocations.has(key)) { - uniqueLocations.add(key) - rows.push(formatRow("Suggested", loc)) + uniqueLocations.add(key); + rows.push(formatRow("Suggested", loc)); } - }) + }); - const csvContent = [headers.join(","), ...rows].join("\n") + const csvContent = [headers.join(","), ...rows].join("\n"); - const blob = new Blob([csvContent], { type: "text/csv" }) - const url = window.URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = "locations.csv" - a.click() - window.URL.revokeObjectURL(url) + const blob = new Blob([csvContent], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "locations.csv"; + a.click(); + window.URL.revokeObjectURL(url); toast({ title: "Success", description: "CSV file downloaded successfully", - }) - } + }); + }; const handleSaveMap = async () => { - const mapElement = document.querySelector(".leaflet-container") + const mapElement = document.querySelector(".leaflet-container"); if (mapElement) { - const canvas = await html2canvas(mapElement as HTMLElement) - const url = canvas.toDataURL("image/png") - const a = document.createElement("a") - a.href = url - a.download = "map.png" - a.click() + const canvas = await html2canvas(mapElement as HTMLElement); + const url = canvas.toDataURL("image/png"); + const a = document.createElement("a"); + a.href = url; + a.download = "map.png"; + a.click(); toast({ title: "Success", description: "Map image saved successfully", - }) + }); } - } + }; const toggleDrawing = () => { - setIsDrawing(!isDrawing) - } + setIsDrawing(!isDrawing); + }; return (
@@ -148,11 +154,17 @@ export default function Index() { {/* Action Buttons */}
- - @@ -161,7 +173,11 @@ export default function Index() { {/* Draw Polygon Button */}
- ) + ); } - diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx index 4552e07..b578a5d 100644 --- a/frontend/src/app/not-found.tsx +++ b/frontend/src/app/not-found.tsx @@ -9,11 +9,18 @@ const NotFound = () => { return (
-

404

-

Oops! The page you're looking for doesn't exist.

+

+ 404 +

+

+ Oops! The page you're looking for doesn't exist. +

- You tried to access {pathname}, but - it's not available. + You tried to access{" "} + + {pathname} + + , but it's not available.

Coming Soon

- Our AI-powered air quality reports are on the way! Stay tuned for real-time insights and advanced analytics - to help you understand air pollution trends like never before. + Our AI-powered air quality reports are on the way! Stay tuned for + real-time insights and advanced analytics to help you understand air + pollution trends like never before.

@@ -48,21 +33,39 @@ function ReportContent() {

- Leveraging artificial intelligence, we analyze real-time air pollution data to provide accurate insights - and actionable recommendations. Our reports cover PM2.5 trends, pollution hotspots, and seasonal variations. + Leveraging artificial intelligence, we analyze real-time air + pollution data to provide accurate insights and actionable + recommendations. Our reports cover PM2.5 trends, pollution hotspots, + and seasonal variations.

- }> - Analyze pollution levels over time to detect patterns and anomalies. + } + > + Analyze pollution levels over time to detect patterns and + anomalies. - }> - Compare air quality across different locations with detailed breakdowns. + } + > + Compare air quality across different locations with detailed + breakdowns. - }> - Understand how pollution affects respiratory health and well-being. + } + > + Understand how pollution affects respiratory health and + well-being. - }> - Smart predictions based on historical data to help communities prepare in advance. + } + > + Smart predictions based on historical data to help communities + prepare in advance.
diff --git a/frontend/src/components/Controls/ControlPanel.tsx b/frontend/src/components/Controls/ControlPanel.tsx index be51465..baf9b51 100644 --- a/frontend/src/components/Controls/ControlPanel.tsx +++ b/frontend/src/components/Controls/ControlPanel.tsx @@ -1,17 +1,17 @@ -"use client" +"use client"; -import { useState } from "react" -import { Button } from "@/ui/button" -import { Input } from "@/ui/input" -import { SearchBar } from "./SearchBar" -import { FileUpload } from "./FileUpload" -import type { Location, ControlPanelProps } from "@/lib/types" -import { useToast } from "@/ui/use-toast" -import { Loader2 } from "lucide-react" +import { useState } from "react"; +import { Button } from "@/ui/button"; +import { Input } from "@/ui/input"; +import { SearchBar } from "./SearchBar"; +import { FileUpload } from "./FileUpload"; +import type { Location, ControlPanelProps } from "@/lib/types"; +import { useToast } from "@/ui/use-toast"; +import { Loader2 } from "lucide-react"; // Extend ControlPanelProps to include onBoundaryFound interface ExtendedControlPanelProps extends ControlPanelProps { - onBoundaryFound: (boundary: Location[]) => void + onBoundaryFound: (boundary: Location[]) => void; } export function ControlPanel({ @@ -21,12 +21,12 @@ export function ControlPanel({ onMustHaveLocationsChange, onBoundaryFound, }: ExtendedControlPanelProps) { - const [minDistance, setMinDistance] = useState("0.5") // Default value for min_distance_km - const [numSensors, setNumSensors] = useState("5") // Default value for num_sensors - const [newLat, setNewLat] = useState("") - const [newLng, setNewLng] = useState("") - const [isLoading, setIsLoading] = useState(false) - const { toast } = useToast() + const [minDistance, setMinDistance] = useState("0.5"); // Default value for min_distance_km + const [numSensors, setNumSensors] = useState("5"); // Default value for num_sensors + const [newLat, setNewLat] = useState(""); + const [newLng, setNewLng] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); // Validation helper function const validateInputs = () => { @@ -35,80 +35,95 @@ export function ControlPanel({ title: "Error", description: "Please draw a polygon on the map", variant: "destructive", - }) - return false + }); + return false; } if (!numSensors || Number.parseInt(numSensors) < 1) { toast({ title: "Error", description: "Please enter a valid number of sensors", variant: "destructive", - }) - return false + }); + return false; } - return true - } + return true; + }; // Handle form submission const handleSubmit = async () => { - if (!validateInputs()) return + if (!validateInputs()) return; const payload: any = { polygon: { coordinates: [ - [...polygon.map((loc) => [loc.lng, loc.lat]), [polygon[0].lng, polygon[0].lat]], // Close the polygon + [ + ...polygon.map((loc) => [loc.lng, loc.lat]), + [polygon[0].lng, polygon[0].lat], + ], // Close the polygon ], }, - must_have_locations: mustHaveLocations.length > 0 ? mustHaveLocations.map((loc) => [loc.lat, loc.lng]) : [], + must_have_locations: + mustHaveLocations.length > 0 + ? mustHaveLocations.map((loc) => [loc.lat, loc.lng]) + : [], num_sensors: Number.parseInt(numSensors, 10), - } + }; // Ensure min_distance_km is only included if valid - const minDistanceValue = Number.parseFloat(minDistance) + const minDistanceValue = Number.parseFloat(minDistance); if (!isNaN(minDistanceValue)) { - payload.min_distance_km = minDistanceValue + payload.min_distance_km = minDistanceValue; } - setIsLoading(true) + setIsLoading(true); try { - await onSubmit(payload) + await onSubmit(payload); toast({ title: "Success", description: "Locations submitted successfully", - }) + }); } catch (error) { + console.log(error); toast({ title: "Error", description: "Failed to submit locations", variant: "destructive", - }) + }); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; // Add a new must-have location const handleAddLocation = () => { - const lat = Number.parseFloat(newLat) - const lng = Number.parseFloat(newLng) - - if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { + const lat = Number.parseFloat(newLat); + const lng = Number.parseFloat(newLng); + + if ( + isNaN(lat) || + isNaN(lng) || + lat < -90 || + lat > 90 || + lng < -180 || + lng > 180 + ) { toast({ title: "Error", - description: "Please enter valid latitude (-90 to 90) and longitude (-180 to 180)", + description: + "Please enter valid latitude (-90 to 90) and longitude (-180 to 180)", variant: "destructive", - }) - return + }); + return; } - onMustHaveLocationsChange([...mustHaveLocations, { lat, lng }]) - setNewLat("") - setNewLng("") + onMustHaveLocationsChange([...mustHaveLocations, { lat, lng }]); + setNewLat(""); + setNewLng(""); toast({ title: "Success", description: "Location added successfully", - }) - } + }); + }; return (
@@ -119,7 +134,9 @@ export function ControlPanel({ {/* Must-Have Locations */}
- +
-
{mustHaveLocations.length} locations added
+
+ {mustHaveLocations.length} locations added +
{/* Minimum Distance */}
- + - +
- ) + ); } - diff --git a/frontend/src/components/Controls/FileUpload.tsx b/frontend/src/components/Controls/FileUpload.tsx index 2908346..d55f5f6 100644 --- a/frontend/src/components/Controls/FileUpload.tsx +++ b/frontend/src/components/Controls/FileUpload.tsx @@ -1,18 +1,21 @@ -import { useRef, useState } from 'react'; -import { Button } from '@/ui/button'; -import { Upload } from 'lucide-react'; -import Papa from 'papaparse'; -import { useToast } from '@/ui/use-toast'; -import type { Location, FileUploadProps } from '@/lib/types'; +import { useRef, useState } from "react"; +import { Button } from "@/ui/button"; +import { Upload } from "lucide-react"; +import Papa from "papaparse"; +import { useToast } from "@/ui/use-toast"; +import type { Location, FileUploadProps } from "@/lib/types"; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB -const ACCEPTED_FILE_TYPE = 'text/csv'; +// const ACCEPTED_FILE_TYPE = 'text/csv'; const normalizeColumnName = (name: string): string => - name.trim().toLowerCase().replace(/[^a-z0-9]/g, ''); + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); -const latitudeAliases = new Set(['lat', 'latitude']); -const longitudeAliases = new Set(['lng', 'lon', 'longitude']); +const latitudeAliases = new Set(["lat", "latitude"]); +const longitudeAliases = new Set(["lng", "lon", "longitude"]); export function FileUpload({ onUpload }: FileUploadProps) { const fileInputRef = useRef(null); @@ -31,41 +34,50 @@ export function FileUpload({ onUpload }: FileUploadProps) { try { const headers = meta.fields?.map(normalizeColumnName) || []; - if (!headers.length) throw new Error('CSV file has no headers.'); + if (!headers.length) throw new Error("CSV file has no headers."); const latHeader = headers.find((h) => latitudeAliases.has(h)); const lngHeader = headers.find((h) => longitudeAliases.has(h)); if (!latHeader && !lngHeader) { - throw new Error('Missing both latitude and longitude columns.'); + throw new Error("Missing both latitude and longitude columns."); } else if (!latHeader) { - throw new Error('Missing latitude column.'); + throw new Error("Missing latitude column."); } else if (!lngHeader) { - throw new Error('Missing longitude column.'); + throw new Error("Missing longitude column."); } const locations: Location[] = data .map((row) => ({ - lat: parseFloat(row[latHeader] || ''), - lng: parseFloat(row[lngHeader] || ''), + lat: parseFloat(row[latHeader] || ""), + lng: parseFloat(row[lngHeader] || ""), })) .filter(({ lat, lng }) => !isNaN(lat) && !isNaN(lng)); - if (!locations.length) throw new Error('No valid latitude/longitude pairs found.'); + if (!locations.length) + throw new Error("No valid latitude/longitude pairs found."); onUpload(locations); - toast({ title: 'Success', description: `Imported ${locations.length} locations.` }); + toast({ + title: "Success", + description: `Imported ${locations.length} locations.`, + }); } catch (error) { toast({ - title: 'Error', - description: error instanceof Error ? error.message : 'Failed to parse CSV.', - variant: 'destructive', + title: "Error", + description: + error instanceof Error ? error.message : "Failed to parse CSV.", + variant: "destructive", }); } }, error: () => { setIsLoading(false); - toast({ title: 'Error', description: 'Failed to read CSV file.', variant: 'destructive' }); + toast({ + title: "Error", + description: "Failed to read CSV file.", + variant: "destructive", + }); }, }); }; @@ -75,7 +87,11 @@ export function FileUpload({ onUpload }: FileUploadProps) { if (!file) return; if (file.size > MAX_FILE_SIZE) { - toast({ title: 'Error', description: 'File size exceeds 5MB limit.', variant: 'destructive' }); + toast({ + title: "Error", + description: "File size exceeds 5MB limit.", + variant: "destructive", + }); return; } @@ -84,7 +100,13 @@ export function FileUpload({ onUpload }: FileUploadProps) { return (
- +
); diff --git a/frontend/src/components/Controls/SearchBar.tsx b/frontend/src/components/Controls/SearchBar.tsx index 074be6e..1acaa0e 100644 --- a/frontend/src/components/Controls/SearchBar.tsx +++ b/frontend/src/components/Controls/SearchBar.tsx @@ -3,7 +3,7 @@ import { Input } from "@/ui/input"; import { Button } from "@/ui/button"; import { Search, X } from "lucide-react"; import { useToast } from "@/ui/use-toast"; -import { Location } from "@/lib/types"; +import { Location } from "@/lib/types"; interface SearchBarProps { onSearch: (query: string) => void; @@ -12,7 +12,9 @@ interface SearchBarProps { export function SearchBar({ onSearch, onBoundaryFound }: SearchBarProps) { const [query, setQuery] = useState(""); - const [suggestions, setSuggestions] = useState<{ name: string; osm_id: number }[]>([]); + const [suggestions, setSuggestions] = useState< + { name: string; osm_id: number }[] + >([]); const [isLoading, setIsLoading] = useState(false); const { toast } = useToast(); @@ -26,10 +28,17 @@ export function SearchBar({ onSearch, onBoundaryFound }: SearchBarProps) { try { const response = await fetch( - `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5` + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent( + query + )}&format=json&limit=5` ); const data = await response.json(); - setSuggestions(data.map((item: any) => ({ name: item.display_name, osm_id: item.osm_id }))); + setSuggestions( + data.map((item: any) => ({ + name: item.display_name, + osm_id: item.osm_id, + })) + ); } catch (error) { console.error("Error fetching suggestions:", error); } @@ -40,7 +49,10 @@ export function SearchBar({ onSearch, onBoundaryFound }: SearchBarProps) { }, [query]); // Handle search (when user submits form or selects a suggestion) - const searchLocation = async (selectedQuery?: string, selectedOsmId?: number) => { + const searchLocation = async ( + selectedQuery?: string, + selectedOsmId?: number + ) => { const searchQuery = selectedQuery || query; if (!searchQuery.trim()) return; @@ -49,12 +61,18 @@ export function SearchBar({ onSearch, onBoundaryFound }: SearchBarProps) { try { const searchResponse = await fetch( - `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(searchQuery)}&format=json&polygon_geojson=1&limit=1` + `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent( + searchQuery + )}&format=json&polygon_geojson=1&limit=1` ); const searchResults = await searchResponse.json(); if (searchResults.length === 0) { - toast({ title: "Location not found", description: "Try another search term.", variant: "destructive" }); + toast({ + title: "Location not found", + description: "Try another search term.", + variant: "destructive", + }); return; } @@ -67,7 +85,9 @@ export function SearchBar({ onSearch, onBoundaryFound }: SearchBarProps) { const boundaryData = await boundaryResponse.json(); if (boundaryData[0]?.geojson?.coordinates?.[0]) { - const boundary = boundaryData[0].geojson.coordinates[0].map(([lng, lat]: number[]) => ({ lat, lng })); + const boundary = boundaryData[0].geojson.coordinates[0].map( + ([lng, lat]: number[]) => ({ lat, lng }) + ); onBoundaryFound(boundary); onSearch(searchQuery); @@ -77,12 +97,24 @@ export function SearchBar({ onSearch, onBoundaryFound }: SearchBarProps) { window.map.setView([center.lat, center.lng], 12); } - toast({ title: "Location found", description: `Boundary drawn for ${searchResults[0].display_name}` }); + toast({ + title: "Location found", + description: `Boundary drawn for ${searchResults[0].display_name}`, + }); } else { - toast({ title: "Boundary not found", description: "No boundary data available", variant: "destructive" }); + toast({ + title: "Boundary not found", + description: "No boundary data available", + variant: "destructive", + }); } } catch (error) { - toast({ title: "Error", description: "Failed to search location", variant: "destructive" }); + console.log("Error searching location:", error); + toast({ + title: "Error", + description: "Failed to search location", + variant: "destructive", + }); } finally { setIsLoading(false); } @@ -90,7 +122,13 @@ export function SearchBar({ onSearch, onBoundaryFound }: SearchBarProps) { return (
-
{ e.preventDefault(); searchLocation(); }} className="flex gap-2"> + { + e.preventDefault(); + searchLocation(); + }} + className="flex gap-2" + > )} -
diff --git a/frontend/src/components/hooks/use-toast.ts b/frontend/src/components/hooks/use-toast.ts index b390744..1660d52 100644 --- a/frontend/src/components/hooks/use-toast.ts +++ b/frontend/src/components/hooks/use-toast.ts @@ -1,75 +1,72 @@ -import * as React from "react" +import * as React from "react"; -import type { - ToastActionElement, - ToastProps, -} from "@/ui/toast" +import type { ToastActionElement, ToastProps } from "@/ui/toast"; -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; -const actionTypes = { +export const actionTypes = { ADD_TOAST: "ADD_TOAST", UPDATE_TOAST: "UPDATE_TOAST", DISMISS_TOAST: "DISMISS_TOAST", REMOVE_TOAST: "REMOVE_TOAST", -} as const +} as const; -let count = 0 +let count = 0; function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); } -type ActionType = typeof actionTypes +export type ActionType = typeof actionTypes; type Action = | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; } | { - type: ActionType["UPDATE_TOAST"] - toast: Partial + type: ActionType["UPDATE_TOAST"]; + toast: Partial; } | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; } | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; interface State { - toasts: ToasterToast[] + toasts: ToasterToast[]; } -const toastTimeouts = new Map>() +const toastTimeouts = new Map>(); const addToRemoveQueue = (toastId: string) => { if (toastTimeouts.has(toastId)) { - return + return; } const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) + toastTimeouts.delete(toastId); dispatch({ type: "REMOVE_TOAST", toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) + }); + }, TOAST_REMOVE_DELAY); - toastTimeouts.set(toastId, timeout) -} + toastTimeouts.set(toastId, timeout); +}; export const reducer = (state: State, action: Action): State => { switch (action.type) { @@ -77,7 +74,7 @@ export const reducer = (state: State, action: Action): State => { return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } + }; case "UPDATE_TOAST": return { @@ -85,19 +82,19 @@ export const reducer = (state: State, action: Action): State => { toasts: state.toasts.map((t) => t.id === action.toast.id ? { ...t, ...action.toast } : t ), - } + }; case "DISMISS_TOAST": { - const { toastId } = action + const { toastId } = action; // ! Side effects ! - This could be extracted into a dismissToast() action, // but I'll keep it here for simplicity if (toastId) { - addToRemoveQueue(toastId) + addToRemoveQueue(toastId); } else { state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) + addToRemoveQueue(toast.id); + }); } return { @@ -110,44 +107,44 @@ export const reducer = (state: State, action: Action): State => { } : t ), - } + }; } case "REMOVE_TOAST": if (action.toastId === undefined) { return { ...state, toasts: [], - } + }; } return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId), - } + }; } -} +}; -const listeners: Array<(state: State) => void> = [] +const listeners: Array<(state: State) => void> = []; -let memoryState: State = { toasts: [] } +let memoryState: State = { toasts: [] }; function dispatch(action: Action) { - memoryState = reducer(memoryState, action) + memoryState = reducer(memoryState, action); listeners.forEach((listener) => { - listener(memoryState) - }) + listener(memoryState); + }); } -type Toast = Omit +type Toast = Omit; function toast({ ...props }: Toast) { - const id = genId() + const id = genId(); const update = (props: ToasterToast) => dispatch({ type: "UPDATE_TOAST", toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); dispatch({ type: "ADD_TOAST", @@ -156,36 +153,36 @@ function toast({ ...props }: Toast) { id, open: true, onOpenChange: (open) => { - if (!open) dismiss() + if (!open) dismiss(); }, }, - }) + }); return { id: id, dismiss, update, - } + }; } function useToast() { - const [state, setState] = React.useState(memoryState) + const [state, setState] = React.useState(memoryState); React.useEffect(() => { - listeners.push(setState) + listeners.push(setState); return () => { - const index = listeners.indexOf(setState) + const index = listeners.indexOf(setState); if (index > -1) { - listeners.splice(index, 1) + listeners.splice(index, 1); } - } - }, [state]) + }; + }, [state]); return { ...state, toast, dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } + }; } -export { useToast, toast } +export { useToast, toast }; diff --git a/frontend/src/components/map/LeafletMap.tsx b/frontend/src/components/map/LeafletMap.tsx index 801d284..0d893e6 100644 --- a/frontend/src/components/map/LeafletMap.tsx +++ b/frontend/src/components/map/LeafletMap.tsx @@ -1,6 +1,6 @@ import "leaflet/dist/leaflet.css"; import React, { useEffect, useRef, useState, useMemo } from "react"; -import ReactDOM from 'react-dom/client'; +import ReactDOM from "react-dom/client"; import { MapContainer, TileLayer, useMap } from "react-leaflet"; import Image from "next/image"; import L from "leaflet"; @@ -43,10 +43,10 @@ interface SatelliteData { const isSatelliteData = (data: any): data is SatelliteData => { return ( data && - typeof data.latitude === 'number' && - typeof data.longitude === 'number' && - typeof data.pm2_5_prediction === 'number' && - typeof data.timestamp === 'string' + typeof data.latitude === "number" && + typeof data.longitude === "number" && + typeof data.pm2_5_prediction === "number" && + typeof data.timestamp === "string" ); }; @@ -54,19 +54,48 @@ const isSatelliteData = (data: any): data is SatelliteData => { const getAirQualityInfo = (pm25: number | null) => { // Handle invalid or null PM2.5 values if (pm25 === null || isNaN(pm25)) { - return { - level: 'Invalid Data', - image: Invalid, - color: 'bg-white border-gray-200' + return { + level: "Invalid Data", + image: Invalid, + color: "bg-white border-gray-200", }; } - if (pm25 <= 9) return { level: 'Good', image: GoodAir, color: 'bg-white border-green-200' }; - if (pm25 <= 35.4) return { level: 'Moderate', image: Moderate, color: 'bg-white border-yellow-200' }; - if (pm25 <= 55.4) return { level: 'Unhealthy for Sensitive Groups', image: UnhealthySG, color: 'bg-white border-orange-200' }; - if (pm25 <= 125.4) return { level: 'Unhealthy', image: Unhealthy, color: 'bg-white border-red-200' }; - if (pm25 <= 225.4) return { level: 'Very Unhealthy', image: VeryUnhealthy, color: 'bg-white border-purple-200' }; - return { level: 'Hazardous', image: Hazardous, color: 'bg-white border-red-300' }; + if (pm25 <= 9) + return { + level: "Good", + image: GoodAir, + color: "bg-white border-green-200", + }; + if (pm25 <= 35.4) + return { + level: "Moderate", + image: Moderate, + color: "bg-white border-yellow-200", + }; + if (pm25 <= 55.4) + return { + level: "Unhealthy for Sensitive Groups", + image: UnhealthySG, + color: "bg-white border-orange-200", + }; + if (pm25 <= 125.4) + return { + level: "Unhealthy", + image: Unhealthy, + color: "bg-white border-red-200", + }; + if (pm25 <= 225.4) + return { + level: "Very Unhealthy", + image: VeryUnhealthy, + color: "bg-white border-purple-200", + }; + return { + level: "Hazardous", + image: Hazardous, + color: "bg-white border-red-300", + }; }; // Create a component for the popup content @@ -75,10 +104,14 @@ const PopupContent: React.FC<{ data: Partial; onClose: () => void; }> = ({ label, data, onClose }) => { - const { level, image, color } = getAirQualityInfo(data.pm2_5_prediction ?? null); - + const { level, image, color } = getAirQualityInfo( + data.pm2_5_prediction ?? null + ); + // Safely format timestamp - const timestamp = data.timestamp ? new Date(data.timestamp).toLocaleString() : 'Unknown'; + const timestamp = data.timestamp + ? new Date(data.timestamp).toLocaleString() + : "Unknown"; return (
@@ -94,23 +127,16 @@ const PopupContent: React.FC<{ priority />
-
{label}
-
- {level} -
+
{level}
- PM2.5: {data.pm2_5_prediction?.toFixed(1) ?? 'N/A'} µg/m³ -
-
- Updated {timestamp} + PM2.5: {data.pm2_5_prediction?.toFixed(1) ?? "N/A"} µg/m³
+
Updated {timestamp}
); }; @@ -125,10 +151,7 @@ const LoadingPopupContent: React.FC<{
-
@@ -145,7 +168,7 @@ const ErrorPopupContent: React.FC<{ label: string; onClose: () => void; errorMessage?: string; -}> = ({ label, onClose, errorMessage = 'Error loading air quality data' }) => ( +}> = ({ label, onClose, errorMessage = "Error loading air quality data" }) => (
@@ -159,23 +182,18 @@ const ErrorPopupContent: React.FC<{ priority />
-
{label}
-
- {errorMessage} -
+
{errorMessage}
); // Add this CSS class to override default Leaflet popup styles const customPopupOptions = { - className: 'custom-popup', + className: "custom-popup", closeButton: false, maxWidth: 300, minWidth: 200, @@ -214,28 +232,29 @@ const SearchControl: React.FC<{ ); // Create and add the search icon - const searchIcon = document.createElement('div'); + const searchIcon = document.createElement("div"); searchIcon.innerHTML = ` `; - searchIcon.className = 'absolute left-3 top-1/2 transform -translate-y-1/2 pointer-events-none'; + searchIcon.className = + "absolute left-3 top-1/2 transform -translate-y-1/2 pointer-events-none"; searchBar.appendChild(searchIcon); // Adjust the search input padding to accommodate the icon - const searchInput = searchBar.querySelector('input'); + const searchInput = searchBar.querySelector("input"); if (searchInput) { - searchInput.style.paddingLeft = '2.5rem'; + searchInput.style.paddingLeft = "2.5rem"; // Add some additional styling to the input searchInput.classList.add( - 'w-full', - 'pl-10', - 'pr-4', - 'py-2', - 'rounded-md', - 'focus:outline-none', - 'focus:border-transparent' + "w-full", + "pl-10", + "pr-4", + "py-2", + "rounded-md", + "focus:outline-none", + "focus:border-transparent" ); } } @@ -258,17 +277,17 @@ const SearchControl: React.FC<{ map.on("geosearch/showlocation", async (result: any) => { try { const { x, y, label } = result.location; - - if (typeof x !== 'number' || typeof y !== 'number' || !label) { - throw new Error('Invalid location data'); + + if (typeof x !== "number" || typeof y !== "number" || !label) { + throw new Error("Invalid location data"); } - + // Center the map on the selected location with animation map.setView([y, x], 13, { animate: true, - duration: 1 + duration: 1, }); - + markersRef.current.forEach((marker) => marker.remove()); markersRef.current = []; @@ -276,21 +295,23 @@ const SearchControl: React.FC<{ markersRef.current.push(marker); // Create a container div for the popup - const container = document.createElement('div'); - + const container = document.createElement("div"); + // Render loading state const root = ReactDOM.createRoot(container); root.render( - { marker.closePopup(); root.unmount(); - }} + }} /> ); - marker.bindPopup(container, { ...customPopupOptions, offset: [0, 0] }).openPopup(); + marker + .bindPopup(container, { ...customPopupOptions, offset: [0, 0] }) + .openPopup(); try { const response = await getSatelliteData({ @@ -300,36 +321,39 @@ const SearchControl: React.FC<{ // Validate API response if (!response || !isSatelliteData(response)) { - throw new Error('Invalid API response format'); + throw new Error("Invalid API response format"); } // Update with actual data root.render( - marker.closePopup()} /> ); - } catch (error) { - console.error('Error fetching air quality data:', error); + console.error("Error fetching air quality data:", error); // Show error state with specific error message root.render( - marker.closePopup()} - errorMessage={error instanceof Error ? error.message : 'Failed to load air quality data'} + errorMessage={ + error instanceof Error + ? error.message + : "Failed to load air quality data" + } /> ); } } catch (error) { - console.error('Error handling location:', error); + console.error("Error handling location:", error); // Handle location processing errors - const errorContainer = document.createElement('div'); + const errorContainer = document.createElement("div"); const errorRoot = ReactDOM.createRoot(errorContainer); errorRoot.render( - {}} errorMessage="Invalid location data received" @@ -404,17 +428,17 @@ const LoadingIndicator: React.FC = ({ isLoading, error }) => { }; // Add a utility function for delay -const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Create a function to fetch with retries const fetchWithRetry = async ( - fetchFn: () => Promise, - retries = 3, + fetchFn: () => Promise, + retries = 3, initialDelay = 2000, // Start with 2 second delay - backoffFactor = 1.5 // Increase delay by 1.5x each retry + backoffFactor = 1.5 // Increase delay by 1.5x each retry ) => { let currentDelay = initialDelay; - + for (let attempt = 0; attempt < retries; attempt++) { try { if (attempt > 0) { @@ -432,7 +456,9 @@ const fetchWithRetry = async ( }; // Create a component for the map nodes -const MapNodes: React.FC<{ onLoadingChange: (state: LoadingState) => void }> = ({ onLoadingChange }) => { +const MapNodes: React.FC<{ + onLoadingChange: (state: LoadingState) => void; +}> = ({ onLoadingChange }) => { const map = useMap(); const [nodes, setNodes] = useState([]); const markersRef = useRef([]); @@ -452,31 +478,37 @@ const MapNodes: React.FC<{ onLoadingChange: (state: LoadingState) => void }> = ( const fetchNodes = async () => { try { onLoadingChange({ isLoading: true, error: null }); - + const data = await fetchWithRetry( getMapNodes, - 3, // Number of retries + 3, // Number of retries 2000, // Initial delay of 2 seconds - 1.5 // Increase delay by 1.5x each retry + 1.5 // Increase delay by 1.5x each retry ); if (data) { // Filter out invalid nodes const validNodes = data.filter(isValidNode); if (validNodes.length === 0) { - onLoadingChange({ isLoading: false, error: 'No valid data points found' }); + onLoadingChange({ + isLoading: false, + error: "No valid data points found", + }); return; } setNodes(validNodes); onLoadingChange({ isLoading: false, error: null }); } else { - onLoadingChange({ isLoading: false, error: 'Failed to load map data' }); + onLoadingChange({ + isLoading: false, + error: "Failed to load map data", + }); } } catch (error) { - console.error('Error fetching nodes:', error); - onLoadingChange({ - isLoading: false, - error: 'Error loading map data' + console.error("Error fetching nodes:", error); + onLoadingChange({ + isLoading: false, + error: "Error loading map data", }); } }; @@ -484,7 +516,7 @@ const MapNodes: React.FC<{ onLoadingChange: (state: LoadingState) => void }> = ( fetchNodes(); return () => { - markersRef.current.forEach(marker => marker.remove()); + markersRef.current.forEach((marker) => marker.remove()); }; }, [map, onLoadingChange]); @@ -493,41 +525,42 @@ const MapNodes: React.FC<{ onLoadingChange: (state: LoadingState) => void }> = ( try { // Clear existing markers - markersRef.current.forEach(marker => marker.remove()); + markersRef.current.forEach((marker) => marker.remove()); markersRef.current = []; // Create new markers for each node - nodes.forEach(node => { + nodes.forEach((node) => { try { // Safely access properties with optional chaining and nullish coalescing const latitude = node?.siteDetails?.approximate_latitude; const longitude = node?.siteDetails?.approximate_longitude; - const siteName = node?.siteDetails?.name || node?.siteDetails?.formatted_name || node?.siteDetails?.location_name || 'Unknown Location'; + const siteName = + node?.siteDetails?.name || + node?.siteDetails?.formatted_name || + node?.siteDetails?.location_name || + "Unknown Location"; const pm25Value = node?.pm2_5?.value; const timestamp = node?.time; - const aqiCategory = node?.aqi_category ?? 'Unknown'; - + const aqiCategory = node?.aqi_category ?? "Unknown"; + // Skip if essential data is missing if (!latitude || !longitude || pm25Value === undefined) { - console.warn('Skipping node due to missing data:', node._id); + console.warn("Skipping node due to missing data:", node._id); return; } // Create container for popup - const container = document.createElement('div'); + const container = document.createElement("div"); const root = ReactDOM.createRoot(container); // Create marker with custom icon based on AQI category - const marker = L.marker( - [latitude, longitude], - { - icon: getCustomIcon(aqiCategory) - } - ).addTo(map); + const marker = L.marker([latitude, longitude], { + icon: getCustomIcon(aqiCategory), + }).addTo(map); // Render popup content root.render( - void }> = ( // Bind popup to marker with custom options marker.bindPopup(container, { ...customPopupOptions, - offset: L.point(0, -20) + offset: L.point(0, -20), }); // Only add mouseover event - remove mouseout event - marker.on('mouseover', () => { + marker.on("mouseover", () => { // Close other popups before opening this one - markersRef.current.forEach(m => { + markersRef.current.forEach((m) => { if (m !== marker) { m.closePopup(); } @@ -559,17 +592,17 @@ const MapNodes: React.FC<{ onLoadingChange: (state: LoadingState) => void }> = ( markersRef.current.push(marker); } catch (error) { - console.error('Error creating marker for node:', node._id, error); + console.error("Error creating marker for node:", node._id, error); } }); } catch (error) { - console.error('Error updating markers:', error); - onLoadingChange({ - isLoading: false, - error: 'Error displaying map markers' + console.error("Error updating markers:", error); + onLoadingChange({ + isLoading: false, + error: "Error displaying map markers", }); } - }, [nodes, map]); + }, [nodes, map, onLoadingChange]); return null; }; @@ -579,33 +612,33 @@ const Legend: React.FC = () => { const pollutantLevels = useMemo( () => [ { - range: '0.0µg/m³ - 9.0µg/m³', - label: 'Air Quality is Good', + range: "0.0µg/m³ - 9.0µg/m³", + label: "Air Quality is Good", image: GoodAir, }, { - range: '9.1µg/m³ - 35.4µg/m³', - label: 'Air Quality is Moderate', + range: "9.1µg/m³ - 35.4µg/m³", + label: "Air Quality is Moderate", image: Moderate, }, { - range: '35.5µg/m³ - 55.4µg/m³', - label: 'Air Quality is Unhealthy for Sensitive Groups', + range: "35.5µg/m³ - 55.4µg/m³", + label: "Air Quality is Unhealthy for Sensitive Groups", image: UnhealthySG, }, { - range: '55.5µg/m³ - 125.4µg/m³', - label: 'Air Quality is Unhealthy', + range: "55.5µg/m³ - 125.4µg/m³", + label: "Air Quality is Unhealthy", image: Unhealthy, }, { - range: '125.5µg/m³ - 225.4µg/m³', - label: 'Air Quality is Very Unhealthy', + range: "125.5µg/m³ - 225.4µg/m³", + label: "Air Quality is Very Unhealthy", image: VeryUnhealthy, }, { - range: '225.5+ µg/m³', - label: 'Air Quality is Hazardous', + range: "225.5+ µg/m³", + label: "Air Quality is Hazardous", image: Hazardous, }, ], @@ -617,10 +650,7 @@ const Legend: React.FC = () => {
{pollutantLevels.map((level, index) => ( -
+
{ const defaultZoom = 4; const [loadingState, setLoadingState] = useState({ isLoading: false, - error: null + error: null, }); return (
- { > + - @@ -678,22 +711,22 @@ const LeafletMap: React.FC = () => { const getCustomIcon = (aqiCategory: string) => { let imageSrc; switch (aqiCategory.toLowerCase()) { - case 'good': + case "good": imageSrc = GoodAir; break; - case 'moderate': + case "moderate": imageSrc = Moderate; break; - case 'unhealthy for sensitive groups': + case "unhealthy for sensitive groups": imageSrc = UnhealthySG; break; - case 'unhealthy': + case "unhealthy": imageSrc = Unhealthy; break; - case 'very unhealthy': + case "very unhealthy": imageSrc = VeryUnhealthy; break; - case 'hazardous': + case "hazardous": imageSrc = Hazardous; break; default: @@ -701,7 +734,7 @@ const getCustomIcon = (aqiCategory: string) => { } return L.icon({ - iconUrl: typeof imageSrc === 'string' ? imageSrc : imageSrc.src, + iconUrl: typeof imageSrc === "string" ? imageSrc : imageSrc.src, iconSize: [40, 40], iconAnchor: [20, 20], popupAnchor: [0, -20], diff --git a/frontend/src/components/map/MapComponent.tsx b/frontend/src/components/map/MapComponent.tsx index c4bb6f9..df25ebe 100644 --- a/frontend/src/components/map/MapComponent.tsx +++ b/frontend/src/components/map/MapComponent.tsx @@ -1,86 +1,101 @@ -"use client" - -import { useEffect, useRef, useState } from "react" -import { MapContainer, TileLayer, useMap, Marker, Polygon } from "react-leaflet" -import L from "leaflet" -import "leaflet/dist/leaflet.css" -import type { Location } from "@/lib/types" -import { NavigationControls } from "./NavigationControls" -import { Button } from "@/ui/button" -import { MapIcon } from "lucide-react" -import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover" +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + MapContainer, + TileLayer, + useMap, + Marker, + Polygon, +} from "react-leaflet"; +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import type { Location } from "@/lib/types"; +import { NavigationControls } from "./NavigationControls"; +import { Button } from "@/ui/button"; +import { MapIcon } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; // Fix for default markers -delete (L.Icon.Default.prototype as any)._getIconUrl +delete (L.Icon.Default.prototype as any)._getIconUrl; // Create custom icons for different marker types const blueIcon = new L.Icon({ - iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png", + iconUrl: + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], - shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", + shadowUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", shadowSize: [41, 41], -}) +}); const greenIcon = new L.Icon({ - iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png", + iconUrl: + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], - shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", + shadowUrl: + "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png", shadowSize: [41, 41], -}) +}); const mapStyles = { streets: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - satellite: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", -} + satellite: + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", +}; -type MapStyle = keyof typeof mapStyles +type MapStyle = keyof typeof mapStyles; interface MapComponentProps { - polygon: Location[] - mustHaveLocations: Location[] - suggestedLocations: Location[] - onPolygonChange: (locations: Location[]) => void - onLocationClick: (location: Location) => void - isDrawing: boolean + polygon: Location[]; + mustHaveLocations: Location[]; + suggestedLocations: Location[]; + onPolygonChange: (locations: Location[]) => void; + onLocationClick: (location: Location) => void; + isDrawing: boolean; } // Add map instance to window for global access declare global { interface Window { - map: L.Map + map: L.Map; } } function MapStyleControl() { - const map = useMap() - const [currentStyle, setCurrentStyle] = useState("streets") + const map = useMap(); + const [currentStyle, setCurrentStyle] = useState("streets"); const changeStyle = (style: MapStyle) => { - setCurrentStyle(style) + setCurrentStyle(style); // Find and remove the existing tile layer map.eachLayer((layer) => { if (layer instanceof L.TileLayer) { - map.removeLayer(layer) + map.removeLayer(layer); } - }) + }); // Add the new tile layer L.tileLayer(mapStyles[style], { attribution: style === "satellite" ? '© ESRI' : '© OpenStreetMap contributors', - }).addTo(map) - } + }).addTo(map); + }; return (
- @@ -98,56 +113,56 @@ function MapStyleControl() {
- ) + ); } function MapController() { - const map = useMap() + const map = useMap(); useEffect(() => { - window.map = map - }, [map]) - return null + window.map = map; + }, [map]); + return null; } function DrawControl({ onPolygonChange, }: { - onPolygonChange: (locations: Location[]) => void + onPolygonChange: (locations: Location[]) => void; }) { - const map = useMap() - const drawingRef = useRef() - const locationsRef = useRef([]) + const map = useMap(); + const drawingRef = useRef(); + const locationsRef = useRef([]); useEffect(() => { const handleClick = (e: L.LeafletMouseEvent) => { - const newLocation = { lat: e.latlng.lat, lng: e.latlng.lng } - locationsRef.current = [...locationsRef.current, newLocation] + const newLocation = { lat: e.latlng.lat, lng: e.latlng.lng }; + locationsRef.current = [...locationsRef.current, newLocation]; if (!drawingRef.current) { - drawingRef.current = L.polyline([], { color: "blue" }).addTo(map) + drawingRef.current = L.polyline([], { color: "blue" }).addTo(map); } - drawingRef.current.setLatLngs(locationsRef.current) - onPolygonChange(locationsRef.current) - } + drawingRef.current.setLatLngs(locationsRef.current); + onPolygonChange(locationsRef.current); + }; - map.on("click", handleClick) + map.on("click", handleClick); return () => { - map.off("click", handleClick) - drawingRef.current?.remove() - } - }, [map, onPolygonChange]) + map.off("click", handleClick); + drawingRef.current?.remove(); + }; + }, [map, onPolygonChange]); - return null + return null; } -export function MapComponent({ +export default function MapComponent({ polygon, mustHaveLocations, suggestedLocations, onPolygonChange, - onLocationClick, + // onLocationClick, isDrawing, }: MapComponentProps) { return ( @@ -167,7 +182,10 @@ export function MapComponent({ {isDrawing && } {polygon.length > 2 && ( - [loc.lat, loc.lng])} pathOptions={{ color: "blue" }} /> + [loc.lat, loc.lng])} + pathOptions={{ color: "blue" }} + /> )} {mustHaveLocations.map((location, index) => ( @@ -189,6 +207,5 @@ export function MapComponent({ {}} />
- ) + ); } - diff --git a/frontend/src/components/navigation/navigation.tsx b/frontend/src/components/navigation/navigation.tsx index 04fa935..292f0cd 100644 --- a/frontend/src/components/navigation/navigation.tsx +++ b/frontend/src/components/navigation/navigation.tsx @@ -1,9 +1,9 @@ -"use client" +"use client"; -import type React from "react" -import Link from "next/link" -import { usePathname } from "next/navigation" -import { cn } from "@/lib/utils" +import type React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; const navItems = [ { name: "Home", href: "/" }, @@ -11,10 +11,12 @@ const navItems = [ { name: "Categorize", href: "/categorize" }, { name: "Reports", href: "/reports" }, { name: "About", href: "/about" }, -] +]; -export default function Navigation({ className, ...props }: React.HTMLAttributes) { - const pathname = usePathname() +export default function Navigation({ + ...props +}: React.HTMLAttributes) { + const pathname = usePathname(); return (
@@ -31,7 +33,7 @@ export default function Navigation({ className, ...props }: React.HTMLAttributes className={cn( "text-sm font-medium text-gray-600 hover:text-gray-900 relative py-2", pathname === item.href && - "text-gray-900 after:absolute after:left-0 after:bottom-0 after:h-0.5 after:w-full after:bg-blue-600", + "text-gray-900 after:absolute after:left-0 after:bottom-0 after:h-0.5 after:w-full after:bg-blue-600" )} > {item.name} @@ -40,6 +42,5 @@ export default function Navigation({ className, ...props }: React.HTMLAttributes
- ) + ); } - diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a7d5d14..6ceeb03 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,23 +1,23 @@ -import axios from "axios" -import { NextResponse } from "next/server" -import { Location, SiteLocatorPayload, SiteLocatorResponse, - SiteInformation, SiteLocation, SiteCategoryResponse, - ControlPanelProps, GridOption, AirQualityReportPayload, - AirQualityReportResponse, DiurnalData, MonthlyData, - DailyMeanData, SatelliteDataPayload, SatelliteDataResponse, - Grid, Site - - } from "./types" - +import { + SiteLocatorPayload, + SiteLocatorResponse, + SiteCategoryResponse, + AirQualityReportPayload, + AirQualityReportResponse, + Grid, +} from "./types"; const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN; -const BASE_URL = process.env.NEXT_PUBLIC_API_URL; +// const BASE_URL = process.env.NEXT_PUBLIC_API_URL; const PUBLIC_LOCATE_API_URL = process.env.NEXT_PUBLIC_LOCATE_API_URL; -const PUBLIC_SITE_CATEGORY_API_URL = process.env.NEXT_PUBLIC_SITE_CATEGORY_API_URL; -const PUBLIC_AIR_QUALITY_REPORT_API_URL_LLM = process.env.NEXT_PUBLIC_AIR_QUALITY_REPORT_API_URL_LLM; -const PUBLIC_SATELLITE_DATA_API_URL = process.env.NEXT_PUBLIC_SATELLITE_DATA_API_URL; -const PUBLIC_DEVICE_DATA_API_URL = process.env.NEXT_PUBLIC_DEVICE_DATA_API_URL; -const PUBLIC_GRID_SUMMARY_API_URL = process.env.NEXT_PUBLIC_GRID_SUMMARY_API_URL; +const PUBLIC_SITE_CATEGORY_API_URL = + process.env.NEXT_PUBLIC_SITE_CATEGORY_API_URL; +const PUBLIC_AIR_QUALITY_REPORT_API_URL_LLM = + process.env.NEXT_PUBLIC_AIR_QUALITY_REPORT_API_URL_LLM; +// const PUBLIC_SATELLITE_DATA_API_URL = process.env.NEXT_PUBLIC_SATELLITE_DATA_API_URL; +// const PUBLIC_DEVICE_DATA_API_URL = process.env.NEXT_PUBLIC_DEVICE_DATA_API_URL; +const PUBLIC_GRID_SUMMARY_API_URL = + process.env.NEXT_PUBLIC_GRID_SUMMARY_API_URL; const requiredEnvVars = { API_TOKEN: process.env.NEXT_PUBLIC_API_TOKEN, @@ -30,103 +30,130 @@ Object.entries(requiredEnvVars).forEach(([key, value]) => { if (!value) throw new Error(`Missing required environment variable: ${key}`); }); +export async function submitLocations( + payload: SiteLocatorPayload +): Promise { + try { + if (!PUBLIC_LOCATE_API_URL || !API_TOKEN) { + throw new Error("API configuration missing"); + } + + console.log("Making API request to:", PUBLIC_LOCATE_API_URL); + console.log("Request payload:", payload); -export async function submitLocations(payload: SiteLocatorPayload): Promise { - try { - if (!PUBLIC_LOCATE_API_URL || !API_TOKEN) { - throw new Error('API configuration missing'); - } - - console.log('Making API request to:', PUBLIC_LOCATE_API_URL); - console.log('Request payload:', payload); - - const response = await fetch(`${PUBLIC_LOCATE_API_URL}?token=${API_TOKEN}`, { - method: 'POST', + const response = await fetch( + `${PUBLIC_LOCATE_API_URL}?token=${API_TOKEN}`, + { + method: "POST", headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', + "Content-Type": "application/json", + Accept: "application/json", }, body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorData = await response.text(); - console.error('API Error Response:', errorData); - throw new Error(`API request failed: ${response.status} ${response.statusText}`); } - - const data = await response.json(); - console.log('API Response data:', data); - return data; - } catch (error) { - console.error('Error submitting locations:', error); - throw error; + ); + + if (!response.ok) { + const errorData = await response.text(); + console.error("API Error Response:", errorData); + throw new Error( + `API request failed: ${response.status} ${response.statusText}` + ); } + + const data = await response.json(); + console.log("API Response data:", data); + return data; + } catch (error) { + console.error("Error submitting locations:", error); + throw error; } - - export async function getSiteCategory(latitude: number, longitude: number): Promise { - try { - if (!PUBLIC_SITE_CATEGORY_API_URL || !API_TOKEN) { - throw new Error('API configuration missing'); - } - - console.log('Making site category API request for:', { latitude, longitude }); - - const response = await fetch( - `${PUBLIC_SITE_CATEGORY_API_URL}?latitude=${latitude}&longitude=${longitude}&token=${API_TOKEN}` +} + +export async function getSiteCategory( + latitude: number, + longitude: number +): Promise { + try { + if (!PUBLIC_SITE_CATEGORY_API_URL || !API_TOKEN) { + throw new Error("API configuration missing"); + } + + console.log("Making site category API request for:", { + latitude, + longitude, + }); + + const response = await fetch( + `${PUBLIC_SITE_CATEGORY_API_URL}?latitude=${latitude}&longitude=${longitude}&token=${API_TOKEN}` + ); + + if (!response.ok) { + const errorData = await response.text(); + console.error("API Error Response:", errorData); + throw new Error( + `API request failed: ${response.status} ${response.statusText}` ); - - if (!response.ok) { - const errorData = await response.text(); - console.error('API Error Response:', errorData); - throw new Error(`API request failed: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - console.log('Site category API Response:', data); - return data; - } catch (error) { - console.error('Error getting site category:', error); - throw error; } + + const data = await response.json(); + console.log("Site category API Response:", data); + return data; + } catch (error) { + console.error("Error getting site category:", error); + throw error; } - - export async function getAirQualityReport(payload: AirQualityReportPayload): Promise { - try { - const response = await fetch(`${PUBLIC_AIR_QUALITY_REPORT_API_URL_LLM}?token=${API_TOKEN}`, { - method: 'POST', +} + +export async function getAirQualityReport( + payload: AirQualityReportPayload +): Promise { + try { + const response = await fetch( + `${PUBLIC_AIR_QUALITY_REPORT_API_URL_LLM}?token=${API_TOKEN}`, + { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorData = await response.text(); - console.error('API Error Response:', errorData); - throw new Error(`API request failed: ${response.status} ${response.statusText}`); } - - const data = await response.json(); - console.log('Air Quality Report Response:', data); - return data; - } catch (error) { - console.error('Error getting air quality report:', error); - throw error; + ); + + if (!response.ok) { + const errorData = await response.text(); + console.error("API Error Response:", errorData); + throw new Error( + `API request failed: ${response.status} ${response.statusText}` + ); } + + const data = await response.json(); + console.log("Air Quality Report Response:", data); + return data; + } catch (error) { + console.error("Error getting air quality report:", error); + throw error; } - - - export async function fetchGrids(): Promise { - try { - const response = await fetch(`${PUBLIC_GRID_SUMMARY_API_URL}?token=${API_TOKEN}`); - if (!response.ok) { - throw new Error(`Failed to fetch grids: ${response.status} ${response.statusText}`); - } - const data = await response.json(); - return data.grids; - } catch (error) { - console.error('Error fetching grids:', error); - throw new Error('Failed to fetch grids: ' + (error instanceof Error ? error.message : 'Unknown error')); +} + +export async function fetchGrids(): Promise { + try { + const response = await fetch( + `${PUBLIC_GRID_SUMMARY_API_URL}?token=${API_TOKEN}` + ); + if (!response.ok) { + throw new Error( + `Failed to fetch grids: ${response.status} ${response.statusText}` + ); } - } \ No newline at end of file + const data = await response.json(); + return data.grids; + } catch (error) { + console.error("Error fetching grids:", error); + throw new Error( + "Failed to fetch grids: " + + (error instanceof Error ? error.message : "Unknown error") + ); + } +} From 4017a3f3ddca8e43e9405fe47fea30ff41e24a3d Mon Sep 17 00:00:00 2001 From: wabinyai Date: Sun, 16 Feb 2025 19:49:17 +0300 Subject: [PATCH 21/55] textarea --- frontend/src/ui/textarea.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 frontend/src/ui/textarea.tsx diff --git a/frontend/src/ui/textarea.tsx b/frontend/src/ui/textarea.tsx new file mode 100644 index 0000000..9f9a6dc --- /dev/null +++ b/frontend/src/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +