Initial Code Commit

This commit is contained in:
2025-10-24 02:07:59 -04:00
commit f099f36838
63 changed files with 4425 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
// components/addDataButton.tsx
"use client";
import { useState } from "react";
import { Button } from "@nextui-org/react";
import { PlusIcon } from "./icons";
import { UploadDataModal } from "./uploadDataModal";
import { useBuilding } from "@/lib/useBuildingData";
interface AddDataButtonProps {
buildingid: string;
}
export default function AddDataButton({ buildingid }: AddDataButtonProps) {
const { updateBuilding } = useBuilding(buildingid);
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<Button
className="w-fit"
startContent={<PlusIcon size={16} />}
onPress={() => setIsModalOpen(true)}
>
Upload new data
</Button>
<UploadDataModal
buildingid={buildingid}
isOpen={isModalOpen}
updateBuilding={updateBuilding}
onClose={() => setIsModalOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,218 @@
// components/emissionsGraph.tsx
import React, { useMemo } from 'react';
import { LineChart, Line, AreaChart, Area, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { useBuilding, Building, ElectricityDataPoint, NaturalGasDataPoint, WasteDataPoint } from '@/lib/useBuildingData';
export type EmissionGraphFilters = {
startDate?: Date | null;
endDate?: Date | null;
showWaste?: boolean;
showElectricity?: boolean;
showGas?: boolean;
}
interface EmissionsGraphProps {
buildingid: string;
filters: EmissionGraphFilters;
graphType: 'line' | 'area' | 'pie';
}
type ChartDataPoint = {
date: string;
electricity: number;
gas: number;
waste: number;
};
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8'];
export default function EmissionsGraph({ buildingid, filters, graphType }: EmissionsGraphProps) {
const { data: building, isLoading, error } = useBuilding(buildingid);
const chartData = useMemo(() => {
if (!building) return [];
const dataMap = new Map<string, Partial<ChartDataPoint>>();
const addDataPoint = (date: Date, type: 'electricity' | 'gas' | 'waste', value: number) => {
const dateString = date.toISOString().split('T')[0];
const existingData = dataMap.get(dateString) || { date: dateString };
existingData[type] = value;
dataMap.set(dateString, existingData);
};
// Collect all unique dates and data points
const allDates = new Set<string>();
const typedDataPoints: { [key: string]: { date: string, value: number }[] } = {
electricity: [],
gas: [],
waste: []
};
if (filters.showElectricity) {
building.electricityUsage.forEach((point: ElectricityDataPoint) => {
const date = new Date(point.timestamp.seconds * 1000);
const dateString = date.toISOString().split('T')[0];
allDates.add(dateString);
typedDataPoints.electricity.push({ date: dateString, value: point.emissions });
});
}
if (filters.showGas) {
building.naturalGasUsage.forEach((point: NaturalGasDataPoint) => {
const date = new Date(point.timestamp.seconds * 1000);
const dateString = date.toISOString().split('T')[0];
allDates.add(dateString);
typedDataPoints.gas.push({ date: dateString, value: point.emissions });
});
}
if (filters.showWaste) {
building.wasteGeneration.forEach((point: WasteDataPoint) => {
const date = new Date(point.timestamp.seconds * 1000);
const dateString = date.toISOString().split('T')[0];
allDates.add(dateString);
typedDataPoints.waste.push({ date: dateString, value: point.emissions });
});
}
// Sort dates and data points
const sortedDates = Array.from(allDates).sort();
Object.values(typedDataPoints).forEach(points => points.sort((a, b) => a.date.localeCompare(b.date)));
// Interpolate missing values
const interpolateValue = (date: string, points: { date: string, value: number }[]) => {
const index = points.findIndex(p => p.date >= date);
if (index === -1) return points[points.length - 1]?.value || 0;
if (index === 0) return points[0].value;
const prev = points[index - 1];
const next = points[index];
const totalDays = (new Date(next.date).getTime() - new Date(prev.date).getTime()) / (1000 * 60 * 60 * 24);
const daysSincePrev = (new Date(date).getTime() - new Date(prev.date).getTime()) / (1000 * 60 * 60 * 24);
return Number((prev.value + (next.value - prev.value) * (daysSincePrev / totalDays)).toFixed(3));
};
// Fill in all data points
sortedDates.forEach(date => {
const point: Partial<ChartDataPoint> = { date };
if (filters.showElectricity) point.electricity = interpolateValue(date, typedDataPoints.electricity);
if (filters.showGas) point.gas = interpolateValue(date, typedDataPoints.gas);
if (filters.showWaste) point.waste = interpolateValue(date, typedDataPoints.waste);
dataMap.set(date, point);
});
// Modify the return statement to truncate values
return Array.from(dataMap.values())
.filter(point => {
const date = new Date(point.date || '');
return (!filters.startDate || date >= filters.startDate) &&
(!filters.endDate || date <= filters.endDate);
})
.map(point => ({
...point,
electricity: point.electricity ? Number(point.electricity.toFixed(3)) : undefined,
gas: point.gas ? Number(point.gas.toFixed(3)) : undefined,
waste: point.waste ? Number(point.waste.toFixed(3)) : undefined,
}));
}, [building, filters]);
const pieChartData = useMemo(() => {
if (!building || !filters.showWaste) return [];
const wasteTypes = new Map<string, number>();
building.wasteGeneration.forEach((point: WasteDataPoint) => {
const type = point.wasteCategory.toLowerCase();
wasteTypes.set(type, (wasteTypes.get(type) || 0) + point.emissions);
});
return Array.from(wasteTypes, ([name, value]) => ({ name, value: Number(value.toFixed(3)) }));
}, [building, filters.showWaste]);
if (isLoading) {
return (
<div className="w-full h-96 bg-gray-200 animate-pulse rounded-lg">
{/* Skeleton content */}
<div className="h-full flex items-center justify-center">
<span className="text-gray-400">Loading...</span>
</div>
</div>
);
}
if (error) return <div>Error: {error.message}</div>;
const renderLineChart = () => (
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis label={{ value: 'Emissions (metric tons CO2e)', angle: -90, position: 'insideLeft', dy: 96 }} />
<Tooltip formatter={(value) => Number(value).toFixed(3)} />
<Legend />
{filters.showElectricity && building && building.electricityUsage.length > 0 &&
<Line type="monotone" dataKey="electricity" stroke="#8884d8" name="Electricity" connectNulls />}
{filters.showGas && building && building.naturalGasUsage.length > 0 &&
<Line type="monotone" dataKey="gas" stroke="#82ca9d" name="Natural Gas" connectNulls />}
{filters.showWaste && building && building.wasteGeneration.length > 0 &&
<Line type="monotone" dataKey="waste" stroke="#ffc658" name="Waste" connectNulls />}
</LineChart>
);
const renderAreaChart = () => (
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis label={{ value: 'Emissions (metric tons CO2e)', angle: -90, position: 'insideLeft' }} />
<Tooltip formatter={(value) => Number(value).toFixed(3)} />
<Legend />
{filters.showElectricity && building && building.electricityUsage.length > 0 &&
<Area type="monotone" dataKey="electricity" stackId="1" stroke="#8884d8" fill="#8884d8" name="Electricity" connectNulls />}
{filters.showGas && building && building.naturalGasUsage.length > 0 &&
<Area type="monotone" dataKey="gas" stackId="1" stroke="#82ca9d" fill="#82ca9d" name="Natural Gas" connectNulls />}
{filters.showWaste && building && building.wasteGeneration.length > 0 &&
<Area type="monotone" dataKey="waste" stackId="1" stroke="#ffc658" fill="#ffc658" name="Waste" connectNulls />}
</AreaChart>
);
const renderPieChart = () => (
<PieChart>
<Pie
data={pieChartData}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="value"
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{pieChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
);
return (
<div className="w-full h-96">
<ResponsiveContainer width="100%" height="100%">
{(() => {
switch (graphType) {
case 'line':
return renderLineChart();
case 'area':
return renderAreaChart();
case 'pie':
return renderPieChart();
default:
return <></>;
}
})()}
</ResponsiveContainer>
</div>
);
}

50
Project/components/face.tsx Executable file
View File

@@ -0,0 +1,50 @@
import React, { useEffect, useState } from "react";
import { motion } from "framer-motion";
interface FaceProps {
bin: string;
isVisible: boolean;
itemPosition: { x: number; y: number } | null;
videoDimensions: { width: number; height: number };
facePosition: { x: number; y: number };
}
const Face: React.FC<FaceProps> = ({ bin, isVisible, itemPosition, videoDimensions, facePosition }) => {
// Calculate eye rotation based on item position
const [eyeRotation, setEyeRotation] = useState<number>(0);
useEffect(() => {
if (itemPosition && isVisible) {
const dx = itemPosition.x - facePosition.x;
const dy = itemPosition.y - facePosition.y;
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
setEyeRotation(angle);
}
}, [itemPosition, isVisible, facePosition]);
return (
<motion.div
animate={{ y: isVisible ? 0 : 100 }} // Animate up when visible
className="face-container"
initial={{ y: 100 }} // Start below the screen
transition={{ duration: 0.5 }}
>
<div className="face">
{/* Face SVG or Graphics */}
<div className="eyes">
<div
className="eye left-eye"
style={{ transform: `rotate(${eyeRotation}deg)` }}
/>
<div
className="eye right-eye"
style={{ transform: `rotate(${eyeRotation}deg)` }}
/>
</div>
</div>
</motion.div>
);
};
export default Face;

230
Project/components/icons.tsx Executable file
View File

@@ -0,0 +1,230 @@
import * as React from "react";
import { IconSvgProps } from "@/types";
export const GithubIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
height={size || height}
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
clipRule="evenodd"
d="M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
);
};
export const MoonFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z"
fill="currentColor"
/>
</svg>
);
export const SunFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g fill="currentColor">
<path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
<path d="M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z" />
</g>
</svg>
);
export const HeartFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M12.62 20.81c-.34.12-.9.12-1.24 0C8.48 19.82 2 15.69 2 8.69 2 5.6 4.49 3.1 7.56 3.1c1.82 0 3.43.88 4.44 2.24a5.53 5.53 0 0 1 4.44-2.24C19.51 3.1 22 5.6 22 8.69c0 7-6.48 11.13-9.38 12.12Z"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
);
export const SearchIcon = (props: IconSvgProps) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="1em"
role="presentation"
viewBox="0 0 24 24"
width="1em"
{...props}
>
<path
d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path
d="M22 22L20 20"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
export const LoadingCircleIcon = ({
size = 24,
className,
...props
}: IconSvgProps & { size?: number; className?: string }) => (
<svg
className={`animate-spin text-current ${className}`}
fill="none"
height={size}
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
fill="currentColor"
/>
</svg>
);
export const LeftArrowIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
height={size || height}
viewBox="0 0 24 24"
width={size || width}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M19 12H5M12 19L5 12L12 5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
export const SettingsIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
fill="currentColor"
height={size || height}
viewBox="0 0 1024 1024"
width={size || width}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M861.227 511.915a284.16 284.16 0 0 0-2.304-36.95 40.79 40.79 0 0 1 19.072-38.4l47.488-26.752a25.515 25.515 0 0 0 9.685-35.242l-90.07-152.406a26.837 26.837 0 0 0-36.095-9.429l-47.446 26.795a43.384 43.384 0 0 1-43.648-2.987 313.856 313.856 0 0 0-65.109-36.693 41.515 41.515 0 0 1-24.405-35.414v-53.333a26.155 26.155 0 0 0-26.368-25.77H421.845a26.155 26.155 0 0 0-26.325 25.727v53.632a41.515 41.515 0 0 1-24.448 35.584 315.35 315.35 0 0 0-65.067 36.566 43.264 43.264 0 0 1-43.69 2.986l-47.446-26.752a26.795 26.795 0 0 0-36.01 9.472L88.832 374.912a25.557 25.557 0 0 0 9.643 35.243l47.402 26.709c13.142 8.15 20.566 23.04 19.115 38.4a295.424 295.424 0 0 0 0 73.515 40.79 40.79 0 0 1-19.03 38.4l-47.487 26.709a25.515 25.515 0 0 0-9.643 35.2l90.027 152.448a26.88 26.88 0 0 0 36.053 9.515l47.403-26.795c14.037-6.997 30.72-5.888 43.648 2.944a315.596 315.596 0 0 0 65.066 36.523 41.515 41.515 0 0 1 24.491 35.584v53.546a26.197 26.197 0 0 0 26.283 25.814h180.181a26.155 26.155 0 0 0 26.368-25.728v-53.846a41.472 41.472 0 0 1 24.32-35.498 312.15 312.15 0 0 0 65.067-36.608A43.264 43.264 0 0 1 761.472 784l47.488 26.88a26.795 26.795 0 0 0 36.053-9.515l89.942-152.405a25.515 25.515 0 0 0-9.6-35.243l-47.446-26.752a40.79 40.79 0 0 1-19.072-38.4c1.622-12.117 2.39-24.405 2.39-36.693v.043zM511.7 654.25a142.25 142.25 0 1 1 .598-284.459 142.25 142.25 0 0 1-.598 284.459z"
fill="currentColor"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0"
/>
</svg>
);
export const PlusIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
height={size || height}
viewBox="0 0 24 24"
width={size || width}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 5V19M5 12H19"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);

80
Project/components/navbar.tsx Executable file
View File

@@ -0,0 +1,80 @@
// components/navbar.tsx
"use client";
import {
Navbar as NextUINavbar,
NavbarContent,
NavbarBrand,
NavbarItem,
} from "@nextui-org/navbar";
import { Button } from "@nextui-org/button";
import { Link } from "@nextui-org/link";
import NextLink from "next/link";
import { usePathname } from 'next/navigation';
import Image from "next/image";
import { siteConfig } from "@/config/site";
import { ThemeSwitch } from "@/components/theme-switch";
import { GithubIcon } from "@/components/icons";
export const Navbar = () => {
const pathname = usePathname();
if (pathname.includes("/buildings/")) {
return <></>;
}
return (
<NextUINavbar maxWidth="xl" position="sticky">
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
<NavbarBrand as="li" className="gap-3">
<NextLink className="flex justify-start items-center" href="/">
<Image
alt="Carbin Logo"
className="h-8 w-8"
height={48}
src="/carbin.png"
width={48}
/>
<p className="text-2xl font-bold font-baskerville">Carbin</p>
</NextLink>
</NavbarBrand>
</NavbarContent>
<NavbarContent className="hidden sm:flex basis-1/5 sm:basis-full" justify="center">
<NavbarItem>
<Button
as={NextLink}
href="/buildings"
className="bg-orange-500 text-white min-w-[120px]"
>
Buildings
</Button>
</NavbarItem>
<NavbarItem>
<Button
as={NextLink}
href="/mission"
className="text-orange-500 min-w-[120px]"
variant="ghost"
>
Our Mission
</Button>
</NavbarItem>
</NavbarContent>
<NavbarContent
className="hidden sm:flex basis-1/5 sm:basis-full"
justify="end"
>
<NavbarItem className="hidden sm:flex gap-2">
<Link isExternal aria-label="Github" href={siteConfig.links.github}>
<GithubIcon className="text-default-500" />
</Link>
<ThemeSwitch />
</NavbarItem>
</NavbarContent>
</NextUINavbar>
);
};

View File

@@ -0,0 +1,53 @@
import { tv } from "tailwind-variants";
export const title = tv({
base: "tracking-tight inline font-semibold",
variants: {
color: {
violet: "from-[#FF1CF7] to-[#b249f8]",
yellow: "from-[#FF705B] to-[#FFB457]",
blue: "from-[#5EA2EF] to-[#0072F5]",
cyan: "from-[#00b7fa] to-[#01cfea]",
green: "from-[#6FEE8D] to-[#17c964]",
pink: "from-[#FF72E1] to-[#F54C7A]",
foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]",
},
size: {
sm: "text-3xl lg:text-4xl",
md: "text-[2.3rem] lg:text-5xl leading-9",
lg: "text-4xl lg:text-6xl",
},
fullWidth: {
true: "w-full block",
},
},
defaultVariants: {
size: "md",
},
compoundVariants: [
{
color: [
"violet",
"yellow",
"blue",
"cyan",
"green",
"pink",
"foreground",
],
class: "bg-clip-text text-transparent bg-gradient-to-b",
},
],
});
export const subtitle = tv({
base: "w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full",
variants: {
fullWidth: {
true: "!w-full",
},
},
defaultVariants: {
fullWidth: true,
},
});

102
Project/components/sidebar.tsx Executable file
View File

@@ -0,0 +1,102 @@
// app/buildings/[buildingid]/sidebar/sidebar.tsx
"use client";
import { Link } from "@nextui-org/link";
import { Avatar } from "@nextui-org/avatar";
import { useState } from "react";
import { Button, Skeleton } from "@nextui-org/react";
import { usePathname } from "next/navigation";
import { ThemeSwitch } from "./theme-switch";
import { useBuilding } from "@/lib/useBuildingData";
import { GithubIcon, LeftArrowIcon } from "@/components/icons";
import { siteConfig } from "@/config/site";
interface SidebarProps {
buildingid: string;
}
export default function Sidebar({ buildingid }: SidebarProps) {
const { data: buildingData, error, isLoading } = useBuilding(buildingid);
const [isExpanded, setIsExpanded] = useState(true);
const pathname = usePathname();
if (pathname.includes("trashcan-mode")) {
return <></>;
}
return (
<div className={`flex flex-col items-center p-4 space-y-4 h-full ${isExpanded ? "w-64" : "w-16"}`}>
{/* Top section with info about building */}
<div className="flex flex-col items-center space-y-4 min-h-64 max-h-64">
{/* Back to all buildings */}
<Link href="/buildings">
<Button startContent={<LeftArrowIcon size={16} />} variant="light">
{"Back to all buildings"}
</Button>
</Link>
{/* Photo of building */}
{isLoading ? (
<Skeleton className="w-24 h-24 rounded-full">
<div className="w-24 h-24 rounded-full bg-default-300" />
</Skeleton>
) : error ? (
<div>Error: {error.message}</div>
) : !buildingData ? (
<div>No building found</div>
) : (
<Avatar
alt={buildingData.name}
className="w-24 h-24"
src={buildingData.imageURL}
/>
)}
{/* Name of building and settings button*/}
{isLoading ? (
<Skeleton className="w-40 h-8 mb-4">
<div className="w-40 h-8 bg-default-300" />
</Skeleton>
) : buildingData ? (
<div className="flex flex-row items-center justify-between">
<h2 className="text-xl font-bold mb-4">{buildingData.name}</h2>
</div>
) : null}
</div>
{/* Middle section with navigation links */}
<nav className="flex flex-col space-y-6 h-full">
<Link color="primary" href={`/buildings/${buildingid}/emissions`}>
{pathname === `/buildings/${buildingid}/emissions` ? <strong>Emissions</strong> : "Emissions"}
</Link>
<Link color="primary" href={`/buildings/${buildingid}/trash`}>
{pathname === `/buildings/${buildingid}/trash` ? <strong>Trash Log</strong> : "Trash Log"}
</Link>
<Link color="primary" href={`/buildings/${buildingid}/trash-scanner`}>
{pathname === `/buildings/${buildingid}/trash-scanner` ? <strong>Trash Scanner</strong> : "Trash Scanner"}
</Link>
<Link color="primary" href={`/buildings/${buildingid}/trashcan-mode`}>
{pathname === `/buildings/${buildingid}/trashcan-mode` ? <strong>Trashcan Mode</strong> : "Trashcan Mode"}
</Link>
</nav>
{/* Bottom section with quick actions */}
<div className="flex items-center space-x-2 bg-default-100 rounded-full p-2">
<ThemeSwitch />
<div className="w-px h-6 bg-divider" /> {/* Vertical divider */}
<Link isExternal aria-label="Github" className="p-0" href={siteConfig.links.github}>
<GithubIcon className="text-default-500" />
</Link>
{/* <div className="w-px h-6 bg-divider" />
<Link aria-label="Settings" className="p-0" href={"/settings"}>
<SettingsIcon className="text-default-500" />
</Link> */}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { FC } from "react";
import { VisuallyHidden } from "@react-aria/visually-hidden";
import { SwitchProps, useSwitch } from "@nextui-org/switch";
import { useTheme } from "next-themes";
import { useIsSSR } from "@react-aria/ssr";
import clsx from "clsx";
import { SunFilledIcon, MoonFilledIcon } from "@/components/icons";
export interface ThemeSwitchProps {
className?: string;
classNames?: SwitchProps["classNames"];
}
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
className,
classNames,
}) => {
const { theme, setTheme } = useTheme();
const isSSR = useIsSSR();
const onChange = () => {
theme === "light" ? setTheme("dark") : setTheme("light");
};
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps,
} = useSwitch({
isSelected: theme === "light" || isSSR,
"aria-label": `Switch to ${theme === "light" || isSSR ? "dark" : "light"} mode`,
onChange,
});
return (
<Component
{...getBaseProps({
className: clsx(
"px-px transition-opacity hover:opacity-80 cursor-pointer",
className,
classNames?.base,
),
})}
>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: clsx(
[
"w-auto h-auto",
"bg-transparent",
"rounded-lg",
"flex items-center justify-center",
"group-data-[selected=true]:bg-transparent",
"!text-default-500",
"pt-px",
"px-0",
"mx-0",
],
classNames?.wrapper,
),
})}
>
{!isSelected || isSSR ? (
<SunFilledIcon size={22} />
) : (
<MoonFilledIcon size={22} />
)}
</div>
</Component>
);
};

View File

@@ -0,0 +1,175 @@
// trashDetection.tsx
/* eslint-disable no-console */
"use client";
import React, { useEffect, useRef, useState, useMemo } from "react";
import { InferenceEngine, CVImage } from "inferencejs";
function RealtimeModel() {
const inferEngine = useMemo(() => new InferenceEngine(), []);
const [modelWorkerId, setModelWorkerId] = useState<string | null>(null);
const modelWorkerIdRef = useRef<string | null>(null);
const [modelLoading, setModelLoading] = useState(false);
const [predictions, setPredictions] = useState<any[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// References to manage media stream and timeouts
const mediaStreamRef = useRef<MediaStream | null>(null);
const detectFrameTimeoutRef = useRef<number | null>(null);
useEffect(() => {
console.log("Component mounted");
setModelLoading(true);
inferEngine
.startWorker("trash-detection-kkthk", 7, "rf_1nBQDUSClLUApDgPjG78qMbBH602")
.then((id) => {
setModelWorkerId(id);
modelWorkerIdRef.current = id;
startWebcam();
})
.catch((error) => {
console.error("Error starting model worker:", error);
});
// Cleanup function to stop the model worker and webcam when the component unmounts
return () => {
console.log("Component unmounting, stopping model worker and webcam");
if (modelWorkerIdRef.current) {
inferEngine.stopWorker(modelWorkerIdRef.current);
console.log(`Stopped model worker with ID: ${modelWorkerIdRef.current}`);
}
stopWebcam();
if (detectFrameTimeoutRef.current) {
clearTimeout(detectFrameTimeoutRef.current);
detectFrameTimeoutRef.current = null;
console.log("Cleared detectFrameTimeoutRef");
}
};
}, [inferEngine]);
const startWebcam = () => {
const constraints = {
audio: false,
video: {
facingMode: "environment",
},
};
navigator.mediaDevices
.getUserMedia(constraints)
.then((stream) => {
mediaStreamRef.current = stream; // Store the stream reference
if (videoRef.current && containerRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.onloadedmetadata = () => {
videoRef.current?.play();
};
videoRef.current.onplay = () => {
if (canvasRef.current && videoRef.current && containerRef.current) {
detectFrame();
}
};
}
})
.catch((error) => {
console.error("Error accessing webcam:", error);
});
};
const stopWebcam = () => {
if (mediaStreamRef.current) {
console.log("Stopping webcam...");
mediaStreamRef.current.getTracks().forEach((track) => {
track.stop();
console.log(`Stopped track: ${track.kind}`);
});
mediaStreamRef.current = null;
} else {
console.log("No media stream to stop.");
}
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.srcObject = null;
console.log("Video paused and srcObject cleared.");
}
};
const detectFrame = () => {
if (!modelWorkerIdRef.current) {
detectFrameTimeoutRef.current = window.setTimeout(detectFrame, 1000 / 3);
return;
}
if (videoRef.current && canvasRef.current) {
const img = new CVImage(videoRef.current);
inferEngine.infer(modelWorkerIdRef.current, img).then((newPredictions) => {
const ctx = canvasRef.current!.getContext("2d")!;
// Clear the canvas
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
// Get the scaling factors
const scaleX = canvasRef.current!.width / (videoRef.current!.videoWidth ?? 1);
const scaleY = canvasRef.current!.height / (videoRef.current!.videoHeight ?? 1);
newPredictions.forEach((prediction: any) => {
const x = (prediction.bbox.x - prediction.bbox.width / 2) * scaleX;
const y = (prediction.bbox.y - prediction.bbox.height / 2) * scaleY;
const width = prediction.bbox.width * scaleX;
const height = prediction.bbox.height * scaleY;
// Draw bounding box
ctx.strokeStyle = prediction.color;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
});
setPredictions(newPredictions);
detectFrameTimeoutRef.current = window.setTimeout(detectFrame, 1000 / 3);
}).catch((error) => {
console.error("Error during inference:", error);
});
}
};
return (
<div ref={containerRef} className="w-full h-screen relative overflow-hidden">
<div className="absolute inset-0">
<video
ref={videoRef}
muted
playsInline
className="absolute top-0 left-0 w-full h-full transform scale-x-[-1]"
>
<track kind="captions" />
</video>
<canvas
ref={canvasRef}
className="absolute top-0 left-0 w-full h-full transform scale-x-[-1]"
/>
</div>
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 w-11/12 max-w-2xl">
<div className="bg-black bg-opacity-50 backdrop-filter backdrop-blur-md rounded-xl p-4 text-white text-center">
{predictions.length > 0 ? (
predictions.map((prediction, index) => (
<div key={index} className="text-lg">
{`${prediction.class} - ${Math.round(prediction.confidence * 100)}%`}
</div>
))
) : (
<div className="text-lg">No Item Detected</div>
)}
</div>
</div>
</div>
);
}
export default RealtimeModel;

View File

@@ -0,0 +1,681 @@
/* eslint-disable no-console */
"use client";
import React, { useEffect, useRef, useState, useMemo } from "react";
import { InferenceEngine, CVImage } from "inferencejs";
import { motion } from "framer-motion";
import { Timestamp } from 'firebase/firestore';
import { useParams } from "next/navigation";
import { useBuilding, WasteDataPoint } from '@/lib/useBuildingData';
import { Card } from "@nextui-org/react";
export const trashItems = [
{
id: "Aluminum-Can",
name: "Aluminum Can",
bin: "Recycling",
co2e: 170,
},
{
id: "Aluminum-Foil",
name: "Aluminum Foil",
bin: "Recycling",
note: "Please rinse and flatten",
co2e: 10,
},
{
id: "Bio-Plastic-Cup",
name: "Bio-Plastic Cup",
bin: "Compost",
co2e: 70,
},
{
id: "Cardboard",
name: "Cardboard",
bin: "Recycling",
note: "Please flatten all cardboard",
co2e: 80,
},
{
id: "Food",
name: "Food",
bin: "Compost",
co2e: 1000,
},
{
id: "Food-Wrapper",
name: "Food Wrapper",
bin: "Landfill",
co2e: 6,
},
{
id: "Paper",
name: "Paper",
bin: "Recycling",
co2e: 8,
},
{
id: "Paper-Cup",
name: "Paper Cup",
bin: "Recycling",
co2e: 11,
},
{
id: "Paper-Plate",
name: "Paper Plate",
bin: "Compost",
co2e: 15,
},
{
id: "Paper-Soft",
name: "Soft Paper",
bin: "Recycling",
co2e: 5,
},
{
id: "Plastic-Bag",
name: "Plastic Bag",
bin: "Landfill",
co2e: 33,
},
{
id: "Plastic-Bottle",
name: "Plastic Bottle",
bin: "Recycling",
note: "Only hard number 1 or 2 bottles",
co2e: 82,
},
{
id: "Plastic-Container",
name: "Plastic Container",
bin: "Recycling",
note: "Only hard plastics number 1 or 2",
co2e: 100,
},
{
id: "Plastic-Cup",
name: "Plastic Cup",
bin: "Recycling",
note: "Only hard plastics number 1 or 2",
co2e: 30,
},
{
id: "Plastic-Utensil",
name: "Plastic Utensil",
bin: "Landfill",
co2e: 8,
},
{
id: "Styrofoam",
name: "Styrofoam",
bin: "Landfill",
co2e: 45,
},
];
interface BBox {
x: number;
y: number;
width: number;
height: number;
}
interface Prediction {
class: string;
confidence: number;
bbox: BBox;
color: string;
}
interface Detection {
className: string;
lastSeen: number;
framesSeen: number;
bbox: BBox;
isActive: boolean;
}
function TrashcanMode() {
// Initialize the inference engine and state variables
const inferEngine = useMemo(() => new InferenceEngine(), []);
const [modelWorkerId, setModelWorkerId] = useState<string | null>(null);
const [modelLoading, setModelLoading] = useState(false);
const [currentItem, setCurrentItem] = useState<any | null>(null); // Current detected item
const [thrownItems, setThrownItems] = useState<string[]>([]); // List of items estimated to be thrown away
const [showCelebration, setShowCelebration] = useState(false); // State to trigger celebration
const [showCamera, setShowCamera] = useState(false); // Default to false as per your preference
const [isHovering, setIsHovering] = useState(false); // State to detect hover over the switch area
// state variables for ripple effect
const [rippleActive, setRippleActive] = useState(false);
const [rippleColor, setRippleColor] = useState<string>('');
const [ripplePosition, setRipplePosition] = useState<{ x: string; y: string }>({ x: '50%', y: '50%' });
// References to DOM elements
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Tracking detections over time
const detectionsRef = useRef<{ [className: string]: Detection }>({}); // Ref to store detection history
// Introduce a ref to keep track of the last active item and its timestamp
const lastActiveItemRef = useRef<{ itemDetails: any | null; timestamp: number }>({
itemDetails: null,
timestamp: 0,
});
// Inside the component, get the building data
const { buildingid } = useParams();
const { data: building, isLoading, error, updateBuilding } = useBuilding(buildingid as string);
// Helper function to get bin emoji
const getBinEmoji = (bin: string) => {
switch (bin) {
case "Recycling":
return "♻️";
case "Compost":
return "🌿";
case "Landfill":
return "🗑️";
default:
return "";
}
};
// Helper function to get item emoji
const getItemEmoji = (itemId: string) => {
switch (itemId) {
case "Aluminum-Can":
return "🥫";
case "Aluminum-Foil":
return "🥄";
case "Bio-Plastic-Cup":
return "🥤";
case "Cardboard":
return "📦";
case "Food":
return "🍎";
case "Food-Wrapper":
return "🍬";
case "Paper":
return "📄";
case "Paper-Cup":
return "☕";
case "Paper-Plate":
return "🍽️";
case "Paper-Soft":
return "📃";
case "Plastic-Bag":
return "🛍️";
case "Plastic-Bottle":
return "🍼";
case "Plastic-Container":
return "🍱";
case "Plastic-Cup":
return "🥛";
case "Plastic-Utensil":
return "🍴";
case "Styrofoam":
return "📦";
default:
return "";
}
};
// helper function for ripple start position
const getBinRippleStartPosition = (bin: string) => {
switch (bin) {
case "Recycling":
return { x: '100%', y: '100%' }; // Bottom-right corner
case "Compost":
return { x: '50%', y: '100%' }; // Bottom-center
case "Landfill":
return { x: '0%', y: '100%' }; // Bottom-left corner
default:
return { x: '50%', y: '50%' }; // Center
}
};
// Effect to start the model worker
useEffect(() => {
if (!modelLoading) {
setModelLoading(true);
inferEngine
.startWorker("trash-detection-kkthk", 7, "rf_1nBQDUSClLUApDgPjG78qMbBH602")
.then((id) => setModelWorkerId(id))
.catch((error) => {
console.error("Error starting model worker:", error);
});
}
}, [inferEngine, modelLoading]);
// Effect to start the webcam when the model worker is ready
useEffect(() => {
if (modelWorkerId) {
startWebcam();
}
}, [modelWorkerId]);
// Function to initialize and start the webcam
const startWebcam = () => {
const constraints = {
audio: false,
video: {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: "environment",
},
};
navigator.mediaDevices
.getUserMedia(constraints)
.then((stream) => {
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.onloadedmetadata = () => {
videoRef.current?.play();
};
videoRef.current.onplay = () => {
detectFrame();
};
}
})
.catch((error) => {
console.error("Error accessing webcam:", error);
});
};
// Function to detect objects in each video frame
const detectFrame = () => {
if (!modelWorkerId || !videoRef.current) {
setTimeout(detectFrame, 1000 / 3);
return;
}
const img = new CVImage(videoRef.current);
inferEngine.infer(modelWorkerId, img).then((predictions: unknown) => {
const typedPredictions = predictions as Prediction[];
const videoWidth = videoRef.current?.videoWidth ?? 640;
const videoHeight = videoRef.current?.videoHeight ?? 480;
const now = Date.now();
// Filter predictions above confidence threshold
const validPredictions = typedPredictions.filter((pred) => pred.confidence >= 0.2);
if (showCamera && canvasRef.current) {
const ctx = canvasRef.current.getContext("2d")!;
const canvasWidth = canvasRef.current.width;
const canvasHeight = canvasRef.current.height;
// Clear the canvas
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// Draw trash can regions
drawTrashcanRegions(ctx, videoWidth, videoHeight, canvasWidth, canvasHeight);
// Get scaling factors
const scaleX = canvasWidth / (videoWidth ?? 1);
const scaleY = canvasHeight / (videoHeight ?? 1);
validPredictions.forEach((pred: Prediction) => {
// Draw bounding box and center point
drawBoundingBox(ctx, pred, scaleX, scaleY);
});
}
validPredictions.forEach((pred: Prediction) => {
const className = pred.class;
const bbox = pred.bbox;
// Initialize tracking for this class if not present
if (!detectionsRef.current[className]) {
detectionsRef.current[className] = {
className: className,
lastSeen: now,
framesSeen: 1,
bbox: bbox,
isActive: false,
};
} else {
// Update tracking info
const detection = detectionsRef.current[className];
detection.lastSeen = now;
detection.framesSeen += 1;
detection.bbox = bbox;
// Mark as active if seen consistently over 3 frames
if (detection.framesSeen >= 3 && !detection.isActive) {
detection.isActive = true;
}
}
});
// Remove stale detections and check if any active detections are present
let activeDetections = Object.values(detectionsRef.current).filter((detection) => {
const timeSinceLastSeen = now - detection.lastSeen;
if (timeSinceLastSeen > 1000) {
// Remove stale detections
if (detection.isActive) {
// Determine if last known position was near the correct trashcan area
const itemDetails = trashItems.find((item) => item.id === detection.className);
if (itemDetails) {
const isNearCorrectTrashcan = checkIfNearTrashcanArea(
detection.bbox,
itemDetails.bin,
videoWidth,
videoHeight
);
if (isNearCorrectTrashcan) {
// Item was likely thrown away in the correct bin
setThrownItems((prevItems) => [...prevItems, detection.className]);
setShowCelebration(true); // Trigger celebration
setTimeout(() => setShowCelebration(false), 3000); // Stop celebration after 3 seconds
// Trigger the ripple effect
setRippleColor(getBinColor(itemDetails.bin));
setRipplePosition(getBinRippleStartPosition(itemDetails.bin));
setRippleActive(true);
setTimeout(() => setRippleActive(false), 3000); // Ripple lasts 3 seconds
const adjustedEmissions = itemDetails.co2e / 1e+3; // Convert kg to tons
const newWasteDataPoint: WasteDataPoint = {
timestamp: Timestamp.now(),
type: itemDetails.id,
trashcanID: '1', // Use trashcan ID 1
wasteCategory: itemDetails.bin,
emissions: adjustedEmissions,
};
// Update the building's waste generation data
const updatedWasteGeneration = [
...(building?.wasteGeneration || []),
newWasteDataPoint,
];
updateBuilding({ wasteGeneration: updatedWasteGeneration });
} else {
// Incorrect bin, do not trigger celebration
setCurrentItem(null);
}
}
}
delete detectionsRef.current[detection.className];
return false;
}
return detection.isActive;
});
// Update the current item for display based on active detections
if (activeDetections.length > 0) {
// Find the most recently seen active detection
activeDetections.sort((a, b) => b.lastSeen - a.lastSeen);
const mostRecentDetection = activeDetections[0];
const itemDetails = trashItems.find((item) => item.id === mostRecentDetection.className);
// Update last active item reference
lastActiveItemRef.current = { itemDetails, timestamp: now };
setCurrentItem(itemDetails);
} else {
// If no active detections, retain the last item for a short duration
if (now - lastActiveItemRef.current.timestamp < 1000) {
setCurrentItem(lastActiveItemRef.current.itemDetails);
} else {
setCurrentItem(null);
lastActiveItemRef.current = { itemDetails: null, timestamp: 0 };
}
}
setTimeout(detectFrame, 1000 / 3);
});
};
// Helper function to draw bounding box and center point
const drawBoundingBox = (ctx: CanvasRenderingContext2D, prediction: Prediction, scaleX: number, scaleY: number) => {
const x = (prediction.bbox.x - prediction.bbox.width / 2) * scaleX;
const y = (prediction.bbox.y - prediction.bbox.height / 2) * scaleY;
const width = prediction.bbox.width * scaleX;
const height = prediction.bbox.height * scaleY;
// Draw bounding box
ctx.strokeStyle = prediction.color || "#FF0000";
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
// Draw center point
ctx.fillStyle = prediction.color || "#FF0000";
ctx.beginPath();
ctx.arc(x + width / 2, y + height / 2, 5, 0, 2 * Math.PI);
ctx.fill();
};
// Helper function to draw trashcan regions
const drawTrashcanRegions = (
ctx: CanvasRenderingContext2D,
videoWidth: number,
videoHeight: number,
canvasWidth: number,
canvasHeight: number
) => {
const trashcanAreas = getTrashcanAreas(videoWidth, videoHeight);
const scaleX = canvasWidth / (videoWidth ?? 1);
const scaleY = canvasHeight / (videoHeight ?? 1);
Object.entries(trashcanAreas).forEach(([bin, area]) => {
const x = area.x * scaleX;
const y = area.y * scaleY;
const width = area.width * scaleX;
const height = area.height * scaleY;
ctx.strokeStyle = getBinColor(bin);
ctx.lineWidth = 2;
ctx.strokeRect(x, y, width, height);
// Optionally, fill the area with transparent color
ctx.fillStyle = getBinColor(bin) + "33"; // Add transparency
ctx.fillRect(x, y, width, height);
});
};
// Helper function to check if the bounding box is near the correct trashcan area
const checkIfNearTrashcanArea = (
bbox: BBox,
correctBin: string,
videoWidth: number,
videoHeight: number
): boolean => {
const centerX = bbox.x;
const centerY = bbox.y;
// Define areas for each trashcan
const trashcanAreas = getTrashcanAreas(videoWidth, videoHeight);
// Check if the center point is within any trashcan area
for (const [bin, area] of Object.entries(trashcanAreas)) {
if (
centerX >= area.x &&
centerX <= area.x + area.width &&
centerY >= area.y &&
centerY <= area.y + area.height
) {
const isCorrect = bin === correctBin;
return isCorrect;
}
}
// If not near any bin
return false;
};
// Helper function to define trashcan areas
const getTrashcanAreas = (videoWidth: number, videoHeight: number) => {
const areaWidth = (videoWidth * 2) / 5; // 2/5 of the screen width
const areaHeight = videoHeight / 2; // 1/2 of the screen height
return {
Recycling: {
x: 0,
y: videoHeight / 2,
width: areaWidth,
height: areaHeight,
},
Compost: {
x: (videoWidth - areaWidth) / 2,
y: videoHeight / 2,
width: areaWidth,
height: areaHeight,
},
Landfill: {
x: videoWidth - areaWidth,
y: videoHeight / 2,
width: areaWidth,
height: areaHeight,
},
};
};
// Helper function to get bin color
const getBinColor = (bin: string) => {
switch (bin) {
case "Recycling":
return "#00aaff"; // Blue
case "Compost":
return "#33cc33"; // Green
case "Landfill":
return "#aaaaaa"; // Gray
default:
return "#ffffff"; // White
}
};
// Helper function to get arrow symbol
const getArrow = (bin: string) => {
switch (bin) {
case "Recycling":
return "→"; // Right arrow
case "Compost":
return "↓"; // Down arrow
case "Landfill":
return "←"; // Left arrow
default:
return "";
}
};
// Render the component
return (
<div
ref={containerRef}
className="w-full h-screen relative overflow-hidden bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-900"
>
{/* Hidden video element for capturing webcam feed */}
<video ref={videoRef} muted playsInline className="hidden">
<track kind="captions" />
</video>
{/* Video and canvas elements for display */}
{showCamera && (
<div className="absolute inset-0">
<video
ref={videoRef}
muted
playsInline
className="absolute top-0 left-0 w-full h-full object-cover opacity-30"
style={{ transform: "scaleX(-1)" }}
>
<track kind="captions" />
</video>
<canvas
ref={canvasRef}
className="absolute top-0 left-0 w-full h-full object-cover pointer-events-none"
style={{ transform: "scaleX(-1)" }}
/>
</div>
)}
{/* Ripple Effect Overlay */}
{rippleActive && (
<div
className="ripple-effect"
style={{
backgroundColor: rippleColor,
left: ripplePosition.x,
top: ripplePosition.y,
}}
/>
)}
{/* Main content */}
<div className="relative z-10 flex flex-col justify-center items-center w-full h-full p-8">
{showCelebration ? (
<motion.div
className="flex flex-col items-center"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, type: "spring", stiffness: 100 }}
>
<span aria-label="Check Mark" className="text-9xl mb-4" role="img">
</span>
<h2 className="text-4xl font-bold text-green-600 dark:text-green-400">
Great job!
</h2>
</motion.div>
) : currentItem ? (
<Card className="w-full max-w-2xl p-8 bg-white dark:bg-gray-800 shadow-lg">
<h1 className="text-5xl font-bold text-center mb-8 text-gray-800 dark:text-gray-200">
{getBinEmoji(currentItem.bin)} {currentItem.bin} {getArrow(currentItem.bin)}
</h1>
<h2 className="text-3xl text-center mb-4 text-gray-700 dark:text-gray-300">
{getItemEmoji(currentItem.id)} {currentItem.name}
</h2>
{currentItem.note && (
<p className="text-xl text-center text-gray-600 dark:text-gray-400">
{currentItem.note}
</p>
)}
</Card>
) : (
<Card className="w-full max-w-2xl p-8 bg-white dark:bg-gray-800 shadow-lg">
<h1 className="text-4xl font-bold text-center mb-8 text-gray-800 dark:text-gray-200">
No Item Detected
</h1>
{thrownItems.length > 0 && (
<div className="text-center">
<h2 className="text-xl font-semibold mb-2 text-gray-700 dark:text-gray-300">
Recently Thrown Items:
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
{Object.entries(
thrownItems.slice(-5).reduce((acc, item) => {
acc[item] = (acc[item] || 0) + 1;
return acc;
}, {} as Record<string, number>)
)
.map(([item, count]) => (count > 1 ? `${item} (${count}x)` : item))
.join(", ")}
</p>
</div>
)}
</Card>
)}
</div>
</div>
);
}
export default TrashcanMode;

View File

@@ -0,0 +1,415 @@
// components/uploadDataModal.tsx
import { useState, useEffect } from "react";
import { Button, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@nextui-org/react";
import { Accordion, AccordionItem } from "@nextui-org/react";
import { AzureKeyCredential, DocumentAnalysisClient } from "@azure/ai-form-recognizer";
import { useRouter } from 'next/navigation';
import { ElectricityDataPoint, NaturalGasDataPoint } from "../lib/useBuildingData";
interface UploadDataModalProps {
isOpen: boolean;
onClose: () => void;
buildingid: string;
updateBuilding: (newData: any) => void;
}
const EMISSIONS_FACTOR = 0.5;
const key = process.env.NEXT_PUBLIC_FORM_RECOGNIZER_KEY;
const endpoint = process.env.NEXT_PUBLIC_FORM_RECOGNIZER_ENDPOINT;
export function UploadDataModal({ isOpen, onClose, buildingid, updateBuilding }: UploadDataModalProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
const [gasFile, setGasFile] = useState<File | null>(null);
const [electricityFile, setElectricityFile] = useState<File | null>(null);
const [gasFileUrl, setGasFileUrl] = useState<string | null>(null);
const [electricityFileUrl, setElectricityFileUrl] = useState<string | null>(null);
const [extractionStatus, setExtractionStatus] = useState<'idle' | 'loading' | 'complete'>('idle');
const [aiExtractionStatus, setAiExtractionStatus] = useState<'idle' | 'loading' | 'complete'>('idle');
const [dataPreview, setDataPreview] = useState<any>(null);
const router = useRouter();
const handleFileUpload = (type: 'gas' | 'electricity', file: File) => {
if (type === 'gas') {
setGasFile(file);
setGasFileUrl(URL.createObjectURL(file));
} else if (type === 'electricity') {
setElectricityFile(file);
setElectricityFileUrl(URL.createObjectURL(file));
}
setIsSubmitted(true);
};
useEffect(() => {
return () => {
if (gasFileUrl) URL.revokeObjectURL(gasFileUrl);
if (electricityFileUrl) URL.revokeObjectURL(electricityFileUrl);
};
}, [gasFileUrl, electricityFileUrl]);
const extractDataFromPDF = async (file: File, type: 'gas' | 'electricity') => {
const client = new DocumentAnalysisClient(endpoint!, new AzureKeyCredential(key!));
const arrayBuffer = await file.arrayBuffer();
const poller = await client.beginAnalyzeDocument("prebuilt-document", arrayBuffer);
const { keyValuePairs } = await poller.pollUntilDone();
if (!keyValuePairs) return [];
const dataPoints: (ElectricityDataPoint | NaturalGasDataPoint)[] = [];
let extractedDate: Date | null = null;
const monthMap: { [key: string]: number } = {
'jan': 0, 'january': 0, 'feb': 1, 'february': 1, 'mar': 2, 'march': 2,
'apr': 3, 'april': 3, 'may': 4, 'jun': 5, 'june': 5, 'jul': 6, 'july': 6,
'aug': 7, 'august': 7, 'sep': 8, 'september': 8, 'oct': 9, 'october': 9,
'nov': 10, 'november': 10, 'dec': 11, 'december': 11
};
for (const { key, value } of keyValuePairs) {
console.log("KEY:", key.content, "VALUE:", value?.content);
if (!value) continue;
const keyLower = key.content.toLowerCase();
const valueLower = value.content.toLowerCase();
// Extract date information
if (keyLower.includes('date') || keyLower.includes('period')) {
console.log("DATE IDENTIFIED:", valueLower);
const dateMatch = valueLower.match(/(\d{1,2})\s*(?:st|nd|rd|th)?\s*(?:of)?\s*([a-z]+)?\s*(\d{4})?/i);
console.log("DATE MATCH:", dateMatch);
if (dateMatch) {
const day = 1; // Always assume 1st of the month
const month = dateMatch[2] ? monthMap[dateMatch[2].toLowerCase()] : new Date().getMonth();
const year = dateMatch[3] ? parseInt(dateMatch[3]) : new Date().getFullYear();
if (year >= 1900 && year <= 2100) {
extractedDate = new Date(year, month, day);
}
}
}
if (type === 'electricity' && keyLower.includes('kwh')) {
const kwh = parseFloat(value.content || '0');
if (kwh !== 0) {
const timestamp = extractedDate || new Date();
timestamp.setHours(0, 0, 0, 0); // Set to midnight
const existingDataIndex = dataPoints.findIndex(point =>
point.timestamp.seconds === timestamp.getTime() / 1000
);
if (existingDataIndex === -1) {
dataPoints.push({
timestamp: { seconds: timestamp.getTime() / 1000, nanoseconds: 0 },
kwh: kwh,
emissions: kwh * EMISSIONS_FACTOR / 1000,
});
} else {
dataPoints[existingDataIndex] = {
...dataPoints[existingDataIndex],
kwh: kwh,
emissions: kwh * EMISSIONS_FACTOR / 1000,
};
}
}
} else if (type === 'gas' && keyLower.includes('therm')) {
const therms = parseFloat(value.content || '0');
if (therms !== 0) {
const timestamp = extractedDate || new Date();
timestamp.setHours(0, 0, 0, 0); // Set to midnight
const existingDataIndex = dataPoints.findIndex(point =>
point.timestamp.seconds === timestamp.getTime() / 1000
);
if (existingDataIndex === -1) {
dataPoints.push({
timestamp: { seconds: timestamp.getTime() / 1000, nanoseconds: 0 },
therms: therms,
emissions: therms * 5.3 / 1000, // approx CO2 emissions for natural gas (5.3 kg CO2 per therm, measured in tons)
});
} else {
dataPoints[existingDataIndex] = {
...dataPoints[existingDataIndex],
therms: therms,
emissions: therms * 5.3 / 1000,
};
}
}
}
}
return dataPoints;
};
const handleExtraction = async () => {
setExtractionStatus('loading');
try {
let newData: any = {};
if (gasFile) {
const gasData = await extractDataFromPDF(gasFile, 'gas');
console.log("Gas data:");
gasData.forEach(dataPoint => {
console.log("Date:", new Date(dataPoint.timestamp.seconds * 1000).toLocaleDateString(), "Therms:", (dataPoint as NaturalGasDataPoint).therms);
});
newData.naturalGasUsage = gasData;
}
if (electricityFile) {
const electricityData = await extractDataFromPDF(electricityFile, 'electricity');
console.log("Electricity data:");
electricityData.forEach(dataPoint => {
console.log("Date:", new Date(dataPoint.timestamp.seconds * 1000).toLocaleDateString(), "kWh:", (dataPoint as ElectricityDataPoint).kwh);
});
newData.electricityUsage = electricityData;
}
setDataPreview(newData);
setExtractionStatus('complete');
// Update the building data
updateBuilding(newData);
} catch (error) {
console.error("Error during extraction:", error);
setExtractionStatus('idle');
}
};
const handleAIExtraction = async () => {
setAiExtractionStatus('loading');
try {
let newData: any = {};
if (gasFile) {
const gasData = await extractDataUsingAI(gasFile, 'gas');
newData.naturalGasUsage = gasData;
}
if (electricityFile) {
const electricityData = await extractDataUsingAI(electricityFile, 'electricity');
newData.electricityUsage = electricityData;
}
setDataPreview(newData);
setAiExtractionStatus('complete');
// Update the building data
updateBuilding(newData);
} catch (error) {
console.error("Error during AI extraction:", error);
setAiExtractionStatus('idle');
}
};
const extractDataUsingAI = async (file: File, type: 'gas' | 'electricity') => {
// Step 1: Convert PDF to image
const formData = new FormData();
formData.append('pdf', file, file.name);
formData.append('type', type);
const pdfToImageResponse = await fetch('/api/pdf-to-image', {
method: 'POST',
body: formData,
});
if (!pdfToImageResponse.ok) {
throw new Error('Failed to convert PDF to image');
}
const { response } = await pdfToImageResponse.json();
console.log("PDF TO IMAGE RESPONSE", response);
// Parse the JSON response
const parsedData: string = response.response;
//Trim the string to remove the "anything before first {" and "and after last }"
const trimmedData = parsedData.replace(/^[^{]*|[^}]*$/g, '');
const parsedTrimmedData = JSON.parse(trimmedData);
console.log("PARSED TRIMMED DATA", parsedTrimmedData);
// Convert the parsed data to the format expected by the application
return parsedTrimmedData.dataPoints.map((point: any) => ({
timestamp: {
seconds: new Date(point.date).getTime() / 1000,
nanoseconds: 0
},
[type === 'gas' ? 'therms' : 'kwh']: point.usage,
emissions: point.usage * (type === 'gas' ? 5.3 : EMISSIONS_FACTOR) / 1000,
}));
};
return (
<Modal backdrop="blur" isOpen={isOpen} size="xl" onClose={onClose}>
<ModalContent>
{!isSubmitted ? (
<>
<ModalHeader>Upload New Data</ModalHeader>
<ModalBody>
<div className="flex space-x-6">
<div
aria-label="Upload gas data"
className="w-full h-40 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center cursor-pointer"
role="button"
tabIndex={0}
onClick={() => document.getElementById('gas-upload')?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileUpload('gas', file);
}}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && gasFile) {
e.preventDefault();
}
}}
>
<p className="text-center p-4">Click or drag to upload gas bill PDF</p>
<input
accept=".pdf"
className="hidden"
id="gas-upload"
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload('gas', file);
}}
/>
</div>
<div
className="w-full h-40 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center cursor-pointer"
role="button"
tabIndex={0}
onClick={() => document.getElementById('electricity-upload')?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileUpload('electricity', file);
}}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && electricityFile) {
e.preventDefault();
}
}}
>
<p className="text-center p-4">Click or drag to upload electricity bill PDF</p>
<input
accept=".pdf"
className="hidden"
id="electricity-upload"
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload('electricity', file);
}}
/>
</div>
</div>
</ModalBody>
<ModalFooter />
</>
) : (
<>
<ModalHeader>Data Uploaded</ModalHeader>
<ModalBody>
<div className="flex flex-col items-center">
<p className="text-4xl"></p>
<p className="text-center mt-4">
Your file has been successfully uploaded! Please wait while we extract the data.
</p>
{(gasFile || electricityFile) && (
<Accordion className="w-full mt-4">
<AccordionItem key="1" aria-label="File Preview" title="File Preview">
{gasFile && gasFileUrl && (
<div>
<p>Gas Bill:</p>
<p>Name: {gasFile.name}</p>
<p>Type: {gasFile.type}</p>
<p>Size: {(gasFile.size / 1024).toFixed(2)} KB</p>
<embed
className="mt-2"
height="500px"
src={gasFileUrl}
type="application/pdf"
width="100%"
/>
</div>
)}
{electricityFile && electricityFileUrl && (
<div className="mt-4">
<p>Electricity Bill:</p>
<p>Name: {electricityFile.name}</p>
<p>Type: {electricityFile.type}</p>
<p>Size: {(electricityFile.size / 1024).toFixed(2)} KB</p>
<embed
className="mt-2"
height="500px"
src={electricityFileUrl}
type="application/pdf"
width="100%"
/>
</div>
)}
</AccordionItem>
<AccordionItem key="2" aria-label="Data Extraction" title="Data Extraction">
{extractionStatus === 'idle' && aiExtractionStatus === 'idle' && (
<div className="flex space-x-4">
<Button
color="primary"
onPress={handleExtraction}
>
Start Form Recognizer Extraction
</Button>
<Button
color="secondary"
onPress={handleAIExtraction}
>
Start AI-Powered Extraction
</Button>
</div>
)}
{extractionStatus === 'loading' && <p>Extracting data using Form Recognizer...</p>}
{aiExtractionStatus === 'loading' && <p>Extracting data using AI...</p>}
{extractionStatus === 'complete' && <p>Form Recognizer extraction complete!</p>}
{aiExtractionStatus === 'complete' && <p>AI-powered extraction complete!</p>}
</AccordionItem>
<AccordionItem key="3" aria-label="Data Preview" title="Data Preview">
{dataPreview ? (
<pre>{JSON.stringify(dataPreview, null, 2)}</pre>
) : (
<p>No data available. Please complete extraction first.</p>
)}
</AccordionItem>
</Accordion>
)}
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}