From 1aec16eb53fa8e2ba2fbf75bfc02b58fcda047fc Mon Sep 17 00:00:00 2001 From: quannhg Date: Tue, 21 May 2024 14:30:49 +0700 Subject: [PATCH 1/2] fix(fire-alert): autoflow on navbar open --- app/home/fire-alert/page.tsx | 11 +++++- app/home/layout.tsx | 50 ++++++++++++++----------- app/home/navbar.tsx | 72 ++++++++++++++++++++++++------------ store/index.ts | 4 ++ 4 files changed, 92 insertions(+), 45 deletions(-) diff --git a/app/home/fire-alert/page.tsx b/app/home/fire-alert/page.tsx index 99da611..03ddf29 100644 --- a/app/home/fire-alert/page.tsx +++ b/app/home/fire-alert/page.tsx @@ -20,6 +20,7 @@ import { components } from "@/types/openapi-spec"; import { apiClient } from "@/lib/apiClient"; import { useCallback, useEffect, useState } from "react"; import { toast } from "@/components/ui/use-toast"; +import { useIsNavBarCollapsed } from "@/store"; type NotificationStatus = "SAFE" | "DANGEROUS" | "IDLE"; const LightKindList = [56]; @@ -30,6 +31,8 @@ export default function HomePage() { components["schemas"]["ResponseRoom"][] >([]); + const { isNavBarCollapsed } = useIsNavBarCollapsed(); + const Header = () => { const buzzerList: { componentId: number; deviceId: number }[] = []; userRoomData.map(({ devices }) => @@ -451,7 +454,13 @@ export default function HomePage() { return (
-
+
{userRoomData.map(({ name, devices }) => devices.map(({ id, components }) => ( & Record<"icon", typeof HomeIcon> & Record<"name", string> } = { + const itemMap: { + [index: string]: Record<"href", string> & + Record<"icon", typeof HomeIcon> & + Record<"name", string>; + } = { home: { icon: HomeIcon, href: "/home/", @@ -54,26 +64,24 @@ function AppBreadcrumb() { return ( - { - pathComponents.map((p) => { - const Icon = itemMap[p].icon; - const href = itemMap[p].href; - const name = itemMap[p].name; + {pathComponents.map((p) => { + const Icon = itemMap[p].icon; + const href = itemMap[p].href; + const name = itemMap[p].name; - return ( - - - - {name} - - -

/

-
-
- ) - }) - } + return ( + + + + {name} + + +

/

+
+
+ ); + })}
- ) + ); } diff --git a/app/home/navbar.tsx b/app/home/navbar.tsx index 3fff1bc..9de1257 100644 --- a/app/home/navbar.tsx +++ b/app/home/navbar.tsx @@ -15,8 +15,18 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useEmailStore, useJwtStore, useLoggedInStore } from "@/store"; -import { TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from "@radix-ui/react-tooltip"; +import { + useEmailStore, + useIsNavBarCollapsed, + useJwtStore, + useLoggedInStore, +} from "@/store"; +import { + TooltipContent, + TooltipPortal, + TooltipProvider, + TooltipTrigger, +} from "@radix-ui/react-tooltip"; import { Tooltip } from "@/components/ui/tooltip"; export function NavigationTab({ @@ -117,17 +127,31 @@ export function AvatarPane({ className = "" }: { className?: string }) { export function NavigationBar() { const [isHoverSidebar, setIsHoverSidebar] = useState(false); const [hoverTimeout, setHoverTimeout] = useState(undefined); - const [isCollapsed, setIsCollapsed] = useState(false); + const { + isNavBarCollapsed: isCollapsed, + setIsNavBarCollapsed: setIsCollapsed, + } = useIsNavBarCollapsed(); return (
{ hoverTimeout && clearTimeout(hoverTimeout); setIsHoverSidebar(true); }} - onMouseLeave={() => setHoverTimeout(setTimeout(() => setIsHoverSidebar(false), 500))} + onMouseEnter={() => { + hoverTimeout && clearTimeout(hoverTimeout); + setIsHoverSidebar(true); + }} + onMouseLeave={() => + setHoverTimeout(setTimeout(() => setIsHoverSidebar(false), 500)) + } > -
+
); diff --git a/store/index.ts b/store/index.ts index 21687f0..4e8b955 100644 --- a/store/index.ts +++ b/store/index.ts @@ -1,6 +1,10 @@ import { createGlobalStore, useInitStoreToLocalStorage } from "./utils"; export const useEmailStore = createGlobalStore("email", ""); +export const useIsNavBarCollapsed = createGlobalStore( + "isNavBarCollapsed", + false +); export const useJwtStore = createGlobalStore("jwt", ""); export const useLoggedInStore = createGlobalStore("loggedIn", false); export const useNotificationPushedStore = createGlobalStore( From 0661e0b6101389a2010b86ca1a2a5b924e79b3dc Mon Sep 17 00:00:00 2001 From: quannhg Date: Tue, 21 May 2024 16:43:45 +0700 Subject: [PATCH 2/2] feat: implement datagram for each room --- app/home/page.tsx | 104 +++++++++ app/home/roomChart.tsx | 443 ++++++++++++++++++++++++++++++++++++++ lib/normalizeChartData.ts | 10 +- 3 files changed, 553 insertions(+), 4 deletions(-) create mode 100644 app/home/roomChart.tsx diff --git a/app/home/page.tsx b/app/home/page.tsx index 9c245ce..fb467b6 100644 --- a/app/home/page.tsx +++ b/app/home/page.tsx @@ -44,6 +44,7 @@ import { useToast } from "@/components/ui/use-toast"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { MultiSelect, OptionType } from "@/components/ui/multiselect"; +import { RoomChart as RoomHistoryChart } from "./roomChart"; const MetricHistoryChart = dynamic(() => import("./metricLineChart"), { ssr: false, @@ -171,6 +172,108 @@ function RoomStatusSection({ const [openDialog, setOpenDialog] = useState(false); + function RoomChart({ roomName }: { roomName: string }) { + const [metricType, setMetricType] = useState("co"); + + const metricTypeMap: Record< + MetricType, + { + title: string; + + subtitle: string; + } + > = { + co: { + title: "CO concentration", + + subtitle: "By ppm", + }, + + flame: { + title: "Heat", + + subtitle: "By °C", + }, + + gas: { + title: "Gas concentration", + + subtitle: "By ppm", + }, + + smoke: { + title: "Smoke concentration", + + subtitle: "By ppm", + }, + }; + + return ( + + + + + + + + + + + + CO Concentration + + + + Smoke + + + + Flame + + + + Gas Leak + + + + + + +
+ +
+
+
+ ); + } + async function onSubmit(newRoom: z.infer) { const res = await apiClient.POST("/api/rooms/", { body: { @@ -339,6 +442,7 @@ function RoomStatusSection({
))}
+ ))} diff --git a/app/home/roomChart.tsx b/app/home/roomChart.tsx new file mode 100644 index 0000000..ebdf652 --- /dev/null +++ b/app/home/roomChart.tsx @@ -0,0 +1,443 @@ +"use client"; + +import React, { BaseSyntheticEvent, useEffect, useState } from "react"; + +import { addDays, endOfDay, format, startOfDay, subDays } from "date-fns"; + +import * as Plotly from "plotly.js-dist-min"; + +import { + ChartData, + MetricChartData, + normalizeChartData, +} from "@/lib/normalizeChartData"; + +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; + +import { useEmailStore, useJwtStore } from "@/store"; + +import { MetricType } from "./page"; + +import { apiClient } from "@/lib/apiClient"; + +import { toast } from "@/components/ui/use-toast"; + +import { components } from "@/types/openapi-spec"; + +type apiFireLog = "smoke-logs" | "fire-logs" | "co-logs" | "gas-logs"; + +const apiFireLogMap: Record = { + smoke: "smoke-logs", + + co: "co-logs", + + flame: "fire-logs", + + gas: "gas-logs", +}; + +export function RoomChart({ + roomName, + + data: _data, + + title, + + subtitle, + + metricType, +}: { + roomName: string; + + data: ChartData; + + title: string; + + subtitle: string; + + metricType: MetricType; +}) { + const { jwt } = useJwtStore(); + + const { email } = useEmailStore(); + + const [data, setData] = useState([]); + + const [currentDate, setCurrentDate] = useState(new Date()); + + const handleDecreaseDate = () => { + setCurrentDate((prevDate) => subDays(prevDate, 1)); + }; + + const handleIncreaseDate = () => { + setCurrentDate((prevDate) => addDays(prevDate, 1)); + }; + + const handlePickDate = (event: BaseSyntheticEvent) => { + setCurrentDate(new Date(event.target.value)); + }; + + useEffect(() => { + const fetchData = async () => { + try { + const start_time = Math.floor(startOfDay(currentDate).getTime() / 1000); + + const end_time = Math.floor(endOfDay(currentDate).getTime() / 1000); + + const metricDataResponse = await apiClient.GET( + `/api/fire-alert/${apiFireLogMap[metricType]}`, + + { + params: { + query: { + email, + + start_time, + + end_time, + }, + }, + + headers: { + jwt, + }, + } + ); + + const roomsResponse = await apiClient.GET(`/api/rooms/`, { + params: { + query: { + email, + }, + }, + + headers: { + jwt, + }, + }); + + const err = metricDataResponse.error || roomsResponse.error; + + if (err) { + toast({ + title: "Fetch room statuses failed", + + description: err.message, + + variant: "destructive", + }); + + return; + } + + const componentRoomMap = convertToComponentRoomMap( + (roomsResponse.data.value! || + []) as components["schemas"]["ResponseRoom"][] + ); + + const metricData = metricDataResponse.data; + + if ("co_logs" in metricData) { + const metricChartData = normalizeChartData( + { + co_logs: metricData.co_logs || [], + }, + + componentRoomMap + ); + + //keep only selected room + + setData( + metricChartData.filter( + ({ roomName: dataRoomName }) => + dataRoomName.toLowerCase() === roomName.toLowerCase() + ) + ); + } + + if ("smoke_logs" in metricData) { + const metricChartData = normalizeChartData( + { + smoke_logs: metricData.smoke_logs || [], + }, + + componentRoomMap + ); + + //keep only selected room + + setData( + metricChartData.filter( + ({ roomName: dataRoomName }) => dataRoomName === roomName + ) + ); + } + + if ("gas_logs" in metricData) { + const metricChartData = normalizeChartData( + { + gas_logs: metricData.gas_logs || [], + }, + + componentRoomMap + ); + + //keep only selected room + + setData( + metricChartData.filter( + ({ roomName: dataRoomName }) => dataRoomName === roomName + ) + ); + } + + if ("fire_logs" in metricData) { + const metricChartData = normalizeChartData( + { + fire_logs: metricData.fire_logs || [], + }, + + componentRoomMap + ); + + //keep only selected room + + setData( + metricChartData.filter( + ({ roomName: dataRoomName }) => dataRoomName === roomName + ) + ); + } + } catch (error) { + console.error("Error fetching data:", error); + } + }; + + fetchData(); + }, [currentDate, email, jwt, metricType, roomName]); + + useEffect(() => { + console.log(data); + + if (data && data.length > 0) { + const dangerLevel: number = 350; + + const plotData: Plotly.Data[] = data.map( + ({ deviceId, timestamp, value }) => ({ + x: timestamp, + + y: value, + + name: `device ${deviceId}`, + + mode: "lines", + + line: { shape: "spline" }, + }) + ); + + const dangerLevelBar = { + x: data[0].timestamp, + + y: new Array(data[0].timestamp.length).fill(dangerLevel), + + mode: "lines", + + name: "Danger Level", + + line: { + dash: "dash", + + color: "red", + }, + }; + + plotData.push(dangerLevelBar); + + const { tickvals, ticktext } = generateTimeTicks(data, 8); + + const layout = { + // title: "Fire Alert Data for One Day", + + xaxis: { + title: "Time", + + tickformat: ",d", + + tickvals, + + ticktext, + }, + + yaxis: { + title: "Fire Alert Level", + }, + }; + + Plotly.newPlot("fireMatrixChart", plotData, layout); + } + }, [data]); + + return ( +
+
+
+

{title}

+ +

+ {subtitle} +

+
+ +
+ + + + + +
+
+ + {data.length !== 0 ? ( +
+ ) : ( +
+ {"There's no data to show"} +
+ )} +
+ ); +} + +function convertToComponentRoomMap( + data: components["schemas"]["ResponseRoom"][] +): Map { + const componentRoomMap = new Map(); + + const roomCounts = new Map(); + + data.forEach((room) => { + const roomName = room.name; + + let hasMultipleComponents = false; + + room.devices.forEach((device) => { + const componentIds = new Set(); + + for (let i = 0; i < device.components.length; i++) { + const component = device.components[i]; + + if (componentRoomMap.has(component.id)) { + continue; + } + + componentIds.add(component.id); + + if (componentIds.size > 1) { + hasMultipleComponents = true; + + break; + } + } + + device.components.forEach((component) => { + let roomCounter = roomCounts.get(roomName) || 0; + + if (componentRoomMap.has(component.id)) { + return; + } + + const finalRoomName = hasMultipleComponents + ? `${roomName} ${roomCounter}` + : roomName; + + componentRoomMap.set(component.id, finalRoomName); + + roomCounts.set(roomName, roomCounter + 1); + }); + }); + }); + + return componentRoomMap; +} + +function generateTimeTicks( + data: MetricChartData[], + + numIntervals: number +): { + tickvals: number[]; + + ticktext: string[]; +} { + const { minTimestamp, maxTimestamp } = getMinMaxTimestamp(data); + + const timeRange = maxTimestamp - minTimestamp; + + const interval = timeRange / numIntervals; + + const tickvals = []; + + const ticktext = []; + + for (let i = 0; i <= numIntervals; i++) { + const tickValue = minTimestamp + i * interval; + + tickvals.push(tickValue); + + ticktext.push( + new Date(tickValue * 1000).toLocaleTimeString([], { + hour: "2-digit", + + minute: "2-digit", + }) + ); + } + + return { tickvals, ticktext }; +} + +function getMinMaxTimestamp(chartDataArray: MetricChartData[]): { + minTimestamp: number; + + maxTimestamp: number; +} { + if (chartDataArray.length === 0) { + return { minTimestamp: -1, maxTimestamp: -1 }; + } + + let minTimestamp = chartDataArray[0].timestamp[0]; + + let maxTimestamp = + chartDataArray[0].timestamp[chartDataArray[0].timestamp.length - 1]; + + for (const chartData of chartDataArray) { + const timestamps = chartData.timestamp; + + const numTimestamps = timestamps.length; + + if (timestamps[0] < minTimestamp) { + minTimestamp = timestamps[0]; + } + + if (timestamps[numTimestamps - 1] > maxTimestamp) { + maxTimestamp = timestamps[numTimestamps - 1]; + } + } + + return { minTimestamp, maxTimestamp }; +} diff --git a/lib/normalizeChartData.ts b/lib/normalizeChartData.ts index e246f2a..21748e2 100644 --- a/lib/normalizeChartData.ts +++ b/lib/normalizeChartData.ts @@ -3,6 +3,7 @@ export type ChartData = { timestamp: TimestampType; value: number; component: number; + id: number; [key: string]: string | number | TimestampType; }[]; }; @@ -14,6 +15,7 @@ export type MetricChartData = { } & ChartLineData; type ChartLineData = { + deviceId: number; timestamp: number[]; value: number[]; }; @@ -25,7 +27,7 @@ export function normalizeChartData( const metricChartData: Record = {}; Object.values(data).forEach((categoryData) => { - categoryData.forEach(({ timestamp, value, component }) => { + categoryData.forEach(({ timestamp, value, component, id }) => { const roomName = componentRoomMap.get(component) || `Unknown room ${component}`; const millis = @@ -33,11 +35,12 @@ export function normalizeChartData( Math.floor(timestamp.nanos_since_epoch / 1e6); if (!metricChartData[roomName]) { - metricChartData[roomName] = { timestamp: [], value: [] }; + metricChartData[roomName] = { timestamp: [], value: [], deviceId: 0 }; } metricChartData[roomName].timestamp.push(millis); metricChartData[roomName].value.push(value); + metricChartData[roomName].deviceId = id; }); }); @@ -47,8 +50,7 @@ export function normalizeChartData( const res: MetricChartData[] = Object.entries(metricChartData).map( ([roomName, chartData]) => ({ - timestamp: chartData.timestamp, - value: chartData.value, + ...chartData, roomName: roomName, }) );