Initial Code Commit
This commit is contained in:
37
Project/components/addDataButton.tsx
Executable file
37
Project/components/addDataButton.tsx
Executable 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
218
Project/components/emissionsGraph.tsx
Executable file
218
Project/components/emissionsGraph.tsx
Executable 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
50
Project/components/face.tsx
Executable 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
230
Project/components/icons.tsx
Executable 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
80
Project/components/navbar.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
53
Project/components/primitives.ts
Executable file
53
Project/components/primitives.ts
Executable 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
102
Project/components/sidebar.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
81
Project/components/theme-switch.tsx
Executable file
81
Project/components/theme-switch.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
175
Project/components/trashDetection.tsx
Executable file
175
Project/components/trashDetection.tsx
Executable 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;
|
||||
681
Project/components/trashcanMode.tsx
Executable file
681
Project/components/trashcanMode.tsx
Executable 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;
|
||||
415
Project/components/uploadDataModal.tsx
Executable file
415
Project/components/uploadDataModal.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user