Add professional historian modal with realtime analytics
This commit is contained in:
+14
-4
@@ -1,12 +1,22 @@
|
||||
import { useState } from "react";
|
||||
import { AppShell } from "../components/layout/AppShell";
|
||||
import { DashboardPage } from "../features/dashboard/pages/DashboardPage";
|
||||
import { MeteoPage } from "../features/meteo/pages/MeteoPage";
|
||||
|
||||
export type AppPage = "dashboard" | "meteo";
|
||||
|
||||
function App() {
|
||||
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
{({ theme}) => (
|
||||
<DashboardPage theme={theme} />
|
||||
)}
|
||||
<AppShell activePage={activePage} onNavigate={setActivePage}>
|
||||
{({ theme }) =>
|
||||
activePage === "meteo" ? (
|
||||
<MeteoPage theme={theme} />
|
||||
) : (
|
||||
<DashboardPage theme={theme} />
|
||||
)
|
||||
}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useNotifications } from "../../features/notifications/hooks/useNotifica
|
||||
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
||||
import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig";
|
||||
import type { TelemetrySnapshot } from "../../types/telemetry";
|
||||
import type { AppPage } from "../../app/App";
|
||||
|
||||
type AppShellRenderProps = {
|
||||
theme: "dark" | "light";
|
||||
@@ -14,14 +15,16 @@ type AppShellRenderProps = {
|
||||
};
|
||||
|
||||
type AppShellProps = {
|
||||
activePage: AppPage;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
children: (props: AppShellRenderProps) => ReactNode;
|
||||
};
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
||||
const telemetry = useTelemetryStream();
|
||||
const notifications = useNotifications();
|
||||
const currentUser = useCurrentUser();
|
||||
const runtime = useRuntimeConfig();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
|
||||
@@ -41,7 +44,13 @@ export function AppShell({ children }: AppShellProps) {
|
||||
>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="relative h-full shrink-0 overflow-hidden">
|
||||
<Sidebar theme={theme} />
|
||||
<Sidebar
|
||||
theme={theme}
|
||||
activePage={activePage}
|
||||
collapsed={sidebarCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-full w-2 bg-gradient-to-r from-[#3A5064]/6 to-transparent blur-sm" />
|
||||
</div>
|
||||
|
||||
@@ -52,6 +61,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
notificationCount={notifications.unreadCount}
|
||||
userInitials={currentUser.initials}
|
||||
theme={theme}
|
||||
activePage={activePage}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AppPage } from "../../app/App";
|
||||
|
||||
type TopBarProps = {
|
||||
connected: boolean;
|
||||
@@ -20,6 +21,7 @@ type TopBarProps = {
|
||||
notificationCount: number;
|
||||
userInitials: string;
|
||||
theme: "dark" | "light";
|
||||
activePage: AppPage | null;
|
||||
onToggleTheme: () => void;
|
||||
};
|
||||
|
||||
@@ -29,6 +31,7 @@ export function TopBar({
|
||||
notificationCount,
|
||||
userInitials,
|
||||
theme,
|
||||
activePage,
|
||||
onToggleTheme,
|
||||
}: TopBarProps) {
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
@@ -93,7 +96,7 @@ export function TopBar({
|
||||
: "text-[28px] font-bold tracking-tight text-[#162434]"
|
||||
}
|
||||
>
|
||||
Painel Principal
|
||||
{pageTitle(activePage)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -218,8 +221,8 @@ export function TopBar({
|
||||
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isDark
|
||||
? "text-[#8FA3B8]"
|
||||
: "text-[#607284]"
|
||||
? "text-[#8FA3B8]"
|
||||
: "text-[#607284]"
|
||||
} ${userMenuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
@@ -307,3 +310,16 @@ export function TopBar({
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function pageTitle(page: AppPage | null) {
|
||||
switch (page) {
|
||||
case "dashboard":
|
||||
return "Painel Principal";
|
||||
|
||||
case "meteo":
|
||||
return "Meteorologia";
|
||||
|
||||
default:
|
||||
return "Painel Principal";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CloudSun,
|
||||
Droplet,
|
||||
Home,
|
||||
@@ -8,70 +10,99 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import logo from "../../assets/logo.png";
|
||||
import type { AppPage } from "../../app/App";
|
||||
|
||||
type SidebarProps = {
|
||||
theme: "dark" | "light";
|
||||
activePage: AppPage;
|
||||
collapsed: boolean;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
onToggleCollapsed: () => void;
|
||||
};
|
||||
|
||||
const navigationItems = [
|
||||
{ label: "Painel Principal", icon: Home, active: true },
|
||||
{ label: "Meteorologia", icon: CloudSun },
|
||||
const navigationItems: {
|
||||
label: string;
|
||||
page: AppPage;
|
||||
icon: React.ElementType;
|
||||
}[] = [
|
||||
{ label: "Painel Principal", page: "dashboard", icon: Home },
|
||||
{ label: "Meteorologia", page: "meteo", icon: CloudSun },
|
||||
];
|
||||
|
||||
const disabledItems = [
|
||||
{ label: "Consola (VNC)", icon: TabletSmartphone },
|
||||
{ label: "Rega", icon: Droplet },
|
||||
{ label: "Clima", icon: Wind },
|
||||
{ label: "Configurações", icon: Settings },
|
||||
];
|
||||
|
||||
export function Sidebar({ theme }: SidebarProps) {
|
||||
export function Sidebar({
|
||||
theme,
|
||||
activePage,
|
||||
collapsed,
|
||||
onNavigate,
|
||||
onToggleCollapsed,
|
||||
}: SidebarProps) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={
|
||||
isDark
|
||||
? "flex min-h-screen w-64 flex-col bg-[#0B1620] px-4 py-5 shadow-[inset_-1px_0_0_0_rgba(80,100,120,0.25),2px_0_12px_rgba(0,0,0,0.15)]"
|
||||
: "flex min-h-screen w-64 flex-col bg-[#EEF3F7] px-4 py-5 shadow-[inset_-1px_0_0_0_rgba(180,190,200,0.5)]"
|
||||
? `${collapsed ? "w-20" : "w-64"} flex h-full flex-col bg-[#0B1620] px-4 py-5 shadow-[inset_-1px_0_0_0_rgba(80,100,120,0.25),2px_0_12px_rgba(0,0,0,0.15)] transition-all duration-200`
|
||||
: `${collapsed ? "w-20" : "w-64"} flex h-full flex-col bg-[#EEF3F7] px-4 py-5 shadow-[inset_-1px_0_0_0_rgba(180,190,200,0.5)] transition-all duration-200`
|
||||
}
|
||||
>
|
||||
<div className="mb-10 flex items-center gap-3 px-2">
|
||||
<div
|
||||
className={
|
||||
collapsed
|
||||
? "mb-10 flex items-center justify-center"
|
||||
: "mb-10 flex items-center gap-3 px-2"
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt="LitoralRegas"
|
||||
className="h-12 w-12 shrink-0 object-contain"
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "truncate text-[16px] font-bold tracking-wide text-white"
|
||||
: "truncate text-[16px] font-bold tracking-wide text-[#162434]"
|
||||
}
|
||||
>
|
||||
LITORAL CENTRAL
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "truncate text-[16px] font-bold tracking-wide text-white"
|
||||
: "truncate text-[16px] font-bold tracking-wide text-[#162434]"
|
||||
}
|
||||
>
|
||||
LITORAL CENTRAL
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#8FA3B8]"
|
||||
: "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#607284]"
|
||||
}
|
||||
>
|
||||
OPERAÇÕES AGRÍCOLAS
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#8FA3B8]"
|
||||
: "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#607284]"
|
||||
}
|
||||
>
|
||||
OPERAÇÕES AGRÍCOLAS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="space-y-2">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = activePage === item.page;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => onNavigate(item.page)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={
|
||||
item.active
|
||||
active
|
||||
? isDark
|
||||
? "flex w-full items-center gap-3 rounded-xl bg-[#18304B] px-4 py-3 text-left text-sm font-semibold text-white"
|
||||
: "flex w-full items-center gap-3 rounded-xl bg-[#DCE8F5] px-4 py-3 text-left text-sm font-semibold text-[#162434]"
|
||||
@@ -80,22 +111,50 @@ export function Sidebar({ theme }: SidebarProps) {
|
||||
: "flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium text-[#445569] hover:bg-[#E2E8F0] hover:text-[#162434]"
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{disabledItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
disabled
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={
|
||||
isDark
|
||||
? "flex w-full cursor-not-allowed items-center gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium text-[#607284] opacity-60"
|
||||
: "flex w-full cursor-not-allowed items-center gap-3 rounded-xl px-4 py-3 text-left text-sm font-medium text-[#8A9AAB] opacity-70"
|
||||
}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
className={
|
||||
isDark
|
||||
? "mt-auto px-4 text-sm text-slate-400"
|
||||
: "mt-auto px-4 text-sm text-[#607284]"
|
||||
? "mt-auto flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm text-slate-400 hover:bg-[#132434] hover:text-white"
|
||||
: "mt-auto flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm text-[#607284] hover:bg-[#E2E8F0] hover:text-[#162434]"
|
||||
}
|
||||
>
|
||||
Recolher menu
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span>Recolher menu</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Maximize2,
|
||||
SlidersHorizontal,
|
||||
TrendingUp,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from "recharts";
|
||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||
|
||||
export type HistorianPoint = {
|
||||
timestamp: string;
|
||||
numericValue: number | null;
|
||||
booleanValue: boolean | null;
|
||||
textValue: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sensor: ModuleSensorResponse | null;
|
||||
theme: "dark" | "light";
|
||||
points: HistorianPoint[];
|
||||
loading: boolean;
|
||||
hours: number;
|
||||
onHoursChange: (hours: number) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "15M", hours: 0.25 },
|
||||
{ label: "30M", hours: 0.5 },
|
||||
{ label: "1H", hours: 1 },
|
||||
{ label: "6H", hours: 6 },
|
||||
{ label: "12H", hours: 12 },
|
||||
{ label: "24H", hours: 24 },
|
||||
{ label: "7D", hours: 168 },
|
||||
{ label: "30D", hours: 720 },
|
||||
{ label: "90D", hours: 2160 },
|
||||
{ label: "1Y", hours: 8760 },
|
||||
];
|
||||
|
||||
const INTERVAL_OPTIONS = [1, 5, 15, 30, 60];
|
||||
|
||||
export function MeteoHistoryModal({
|
||||
sensor,
|
||||
theme,
|
||||
points,
|
||||
loading,
|
||||
hours,
|
||||
onHoursChange,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const [intervalMinutes, setIntervalMinutes] = useState(5);
|
||||
const [showIndicators, setShowIndicators] = useState(true);
|
||||
const [showCompareLine, setShowCompareLine] = useState(false);
|
||||
const [chartMode, setChartMode] = useState<"area" | "line">("area");
|
||||
const [zeroBaseline, setZeroBaseline] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const [intervalOpen, setIntervalOpen] = useState(false);
|
||||
|
||||
const rawData = useMemo(
|
||||
() =>
|
||||
points
|
||||
.map((point) => {
|
||||
const value =
|
||||
point.numericValue ??
|
||||
(point.booleanValue === null
|
||||
? null
|
||||
: point.booleanValue
|
||||
? 1
|
||||
: 0);
|
||||
|
||||
return {
|
||||
timestamp: point.timestamp,
|
||||
date: new Date(point.timestamp),
|
||||
value,
|
||||
};
|
||||
})
|
||||
.filter((point): point is { timestamp: string; date: Date; value: number } =>
|
||||
typeof point.value === "number",
|
||||
),
|
||||
[points],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (rawData.length === 0) return [];
|
||||
|
||||
const buckets = new Map<number, { total: number; count: number; timestamp: string }>();
|
||||
const intervalMs = intervalMinutes * 60 * 1000;
|
||||
|
||||
for (const point of rawData) {
|
||||
const bucket = Math.floor(point.date.getTime() / intervalMs) * intervalMs;
|
||||
const current = buckets.get(bucket);
|
||||
|
||||
if (current) {
|
||||
current.total += point.value;
|
||||
current.count += 1;
|
||||
} else {
|
||||
buckets.set(bucket, {
|
||||
total: point.value,
|
||||
count: 1,
|
||||
timestamp: new Date(bucket).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(buckets.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, bucket]) => ({
|
||||
timestamp: bucket.timestamp,
|
||||
time: new Date(bucket.timestamp).toLocaleString("pt-PT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
value: bucket.total / bucket.count,
|
||||
}));
|
||||
}, [rawData, intervalMinutes]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const values = chartData.map((point) => point.value);
|
||||
|
||||
if (values.length === 0) {
|
||||
return {
|
||||
current: null,
|
||||
average: null,
|
||||
max: null,
|
||||
min: null,
|
||||
change: null,
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const current = values[values.length - 1];
|
||||
const first = values[0];
|
||||
|
||||
return {
|
||||
current,
|
||||
average: values.reduce((sum, value) => sum + value, 0) / values.length,
|
||||
max: Math.max(...values),
|
||||
min: Math.min(...values),
|
||||
change: current - first,
|
||||
count: values.length,
|
||||
};
|
||||
}, [chartData]);
|
||||
|
||||
const miniBars = useMemo(() => {
|
||||
const chunkSize = Math.max(1, Math.ceil(chartData.length / 28));
|
||||
|
||||
return Array.from({ length: 28 }, (_, index) => {
|
||||
const chunk = chartData.slice(index * chunkSize, (index + 1) * chunkSize);
|
||||
|
||||
return {
|
||||
name: index,
|
||||
value: chunk.length,
|
||||
};
|
||||
});
|
||||
}, [chartData]);
|
||||
|
||||
if (!sensor) return null;
|
||||
|
||||
const unit = sensor.unit ?? "";
|
||||
const yDomain: [number | "auto", number | "auto"] = zeroBaseline ? [0, "auto"] : ["auto", "auto"];
|
||||
|
||||
const Chart = chartMode === "area" ? AreaChart : LineChart;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-md">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} flex w-full flex-col overflow-hidden rounded-2xl border border-[#24384d] bg-[#071120] text-white shadow-2xl`
|
||||
: `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} flex w-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-950 shadow-2xl`
|
||||
}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-5 px-6 py-4">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-black uppercase tracking-[0.42em] text-cyan-400">
|
||||
Histórico
|
||||
</p>
|
||||
<h2 className="text-xl font-black">{sensor.name}</h2>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Chave: meteo.{sensor.key}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col px-6 pb-5">
|
||||
<section className={
|
||||
isDark
|
||||
? "flex min-h-0 flex-1 flex-col rounded-xl border border-[#24384d] bg-[#0a1728] p-4"
|
||||
: "flex min-h-0 flex-1 flex-col rounded-xl border border-slate-200 bg-slate-50 p-4"
|
||||
}>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{RANGE_OPTIONS.map((range) => (
|
||||
<button
|
||||
key={range.label}
|
||||
type="button"
|
||||
onClick={() => onHoursChange(range.hours)}
|
||||
className={
|
||||
hours === range.hours
|
||||
? "rounded-lg bg-cyan-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg shadow-cyan-400/20"
|
||||
: isDark
|
||||
? "rounded-lg bg-slate-900/70 px-4 py-2 text-xs font-bold text-slate-300 hover:bg-slate-800"
|
||||
: "rounded-lg bg-white px-4 py-2 text-xs font-bold text-slate-600 hover:bg-slate-100"
|
||||
}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntervalOpen((value) => !value)}
|
||||
className={
|
||||
isDark
|
||||
? "flex h-[36px] items-center gap-2 rounded-lg border border-slate-700 bg-[#0a1728] px-4 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
||||
: "flex h-[36px] items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 text-xs font-semibold text-slate-700 hover:bg-slate-100"
|
||||
}
|
||||
>
|
||||
Intervalo: {intervalMinutes}m
|
||||
|
||||
<svg
|
||||
className={`h-3 w-3 transition ${intervalOpen ? "rotate-180" : ""}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{intervalOpen && (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-slate-700 bg-[#0b1828] shadow-2xl"
|
||||
: "absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl"
|
||||
}
|
||||
>
|
||||
{INTERVAL_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIntervalMinutes(option);
|
||||
setIntervalOpen(false);
|
||||
}}
|
||||
className={
|
||||
intervalMinutes === option
|
||||
? "flex w-full items-center justify-between bg-cyan-400/10 px-4 py-3 text-left text-xs font-bold text-cyan-300"
|
||||
: isDark
|
||||
? "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-300 hover:bg-slate-800"
|
||||
: "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-700 hover:bg-slate-100"
|
||||
}
|
||||
>
|
||||
<span>{option} minutos</span>
|
||||
|
||||
{intervalMinutes === option && (
|
||||
<div className="h-2 w-2 rounded-full bg-cyan-300" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCompareLine((value) => !value)}
|
||||
className={toggleClass(isDark, showCompareLine)}
|
||||
>
|
||||
Comparar
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIndicators((value) => !value)}
|
||||
className={toggleClass(isDark, showIndicators)}
|
||||
>
|
||||
<Activity className="mr-2 inline h-4 w-4" />
|
||||
Indicadores
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-6 text-xs">
|
||||
<span className="font-semibold">
|
||||
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-full bg-cyan-400" />
|
||||
{sensor.name}
|
||||
{unit && ` (${unit})`}
|
||||
</span>
|
||||
|
||||
<span>Média: <b>{formatValue(stats.average, unit)}</b></span>
|
||||
<span>Máx: <b>{formatValue(stats.max, unit)}</b></span>
|
||||
<span>Mín: <b>{formatValue(stats.min, unit)}</b></span>
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<IconButton
|
||||
isDark={isDark}
|
||||
active={chartMode === "area"}
|
||||
onClick={() => setChartMode(chartMode === "area" ? "line" : "area")}
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
/>
|
||||
<IconButton
|
||||
isDark={isDark}
|
||||
active={zeroBaseline}
|
||||
onClick={() => setZeroBaseline((value) => !value)}
|
||||
icon={<SlidersHorizontal className="h-4 w-4" />}
|
||||
/>
|
||||
<IconButton
|
||||
isDark={isDark}
|
||||
active={expanded}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
icon={<Maximize2 className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={expanded ? "min-h-0 flex-1" : "h-[300px]"}
|
||||
>
|
||||
{loading ? (
|
||||
<EmptyState>A carregar histórico...</EmptyState>
|
||||
) : chartData.length === 0 ? (
|
||||
<EmptyState>Sem dados históricos para este período.</EmptyState>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Chart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.34} />
|
||||
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0.03} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid stroke="#1f3348" strokeDasharray="3 3" vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
minTickGap={42}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#334155" }}
|
||||
width={44}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "#94a3b8",
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const value = Number(payload[0].value);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700 bg-[#111827] px-4 py-3 text-sm shadow-2xl">
|
||||
<p className="mb-2 text-xs text-slate-400">{label}</p>
|
||||
|
||||
<p className="font-bold text-cyan-300">
|
||||
Atual: {formatValue(value, unit)}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-slate-300">
|
||||
Média: {formatValue(stats.average, unit)}
|
||||
</p>
|
||||
|
||||
<p className="text-slate-300">
|
||||
Máx: {formatValue(stats.max, unit)}
|
||||
</p>
|
||||
|
||||
<p className="text-slate-300">
|
||||
Mín: {formatValue(stats.min, unit)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{showIndicators && stats.average !== null && (
|
||||
<ReferenceLine
|
||||
y={stats.average}
|
||||
stroke="#22d3ee"
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.45}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCompareLine && stats.current !== null && (
|
||||
<ReferenceLine
|
||||
y={stats.current}
|
||||
stroke="#a78bfa"
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.55}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartMode === "area" ? (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={2}
|
||||
fill="url(#historyFill)"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: "#22d3ee",
|
||||
stroke: "#071120",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
) : (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
r: 5,
|
||||
fill: "#22d3ee",
|
||||
stroke: "#071120",
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
</Chart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showIndicators && (
|
||||
<section
|
||||
className="mt-3 grid shrink-0 grid-cols-5 gap-3"
|
||||
>
|
||||
<MetricCard theme={theme} title="Valor atual" value={formatValue(stats.current, unit)} sub="Agora há pouco" />
|
||||
<MetricCard theme={theme} title="Variação" value={formatSignedValue(stats.change, unit)} sub="No período" positive={(stats.change ?? 0) >= 0} />
|
||||
<MetricCard theme={theme} title="Máxima" value={formatValue(stats.max, unit)} sub="No período" />
|
||||
<MetricCard theme={theme} title="Mínima" value={formatValue(stats.min, unit)} sub="No período" />
|
||||
|
||||
<div className={cardClass(isDark)}>
|
||||
<p className="text-xs text-slate-400">Volume de Dados</p>
|
||||
<h3 className="mt-1 text-xl font-black">{stats.count.toLocaleString("pt-PT")}</h3>
|
||||
<p className="text-xs text-slate-400">pontos</p>
|
||||
|
||||
<div className="mt-2 h-9">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={miniBars}>
|
||||
<Bar dataKey="value" fill="#4f46e5" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: string }) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-slate-400">{children}</div>;
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
isDark,
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={toggleIconClass(isDark, Boolean(active))}>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
theme,
|
||||
title,
|
||||
value,
|
||||
sub,
|
||||
positive,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
title: string;
|
||||
value: string;
|
||||
sub: string;
|
||||
positive?: boolean;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className={cardClass(isDark)}>
|
||||
<p className="text-xs text-slate-400">{title}</p>
|
||||
<h3 className={`mt-1 text-xl font-black ${positive ? "text-emerald-400" : ""}`}>{value}</h3>
|
||||
<p className="text-xs text-slate-400">{sub}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-lg border border-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 hover:bg-slate-800"
|
||||
: "rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-100";
|
||||
}
|
||||
|
||||
function iconButtonClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-lg border border-slate-700 p-2 text-slate-300 hover:bg-slate-800"
|
||||
: "rounded-lg border border-slate-200 p-2 text-slate-600 hover:bg-slate-100";
|
||||
}
|
||||
|
||||
function toggleClass(isDark: boolean, active: boolean) {
|
||||
if (active) {
|
||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 px-3 py-2 text-xs font-semibold text-cyan-300";
|
||||
}
|
||||
|
||||
return buttonClass(isDark);
|
||||
}
|
||||
|
||||
function toggleIconClass(isDark: boolean, active: boolean) {
|
||||
if (active) {
|
||||
return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 p-2 text-cyan-300";
|
||||
}
|
||||
|
||||
return iconButtonClass(isDark);
|
||||
}
|
||||
|
||||
function cardClass(isDark: boolean) {
|
||||
return isDark
|
||||
? "rounded-xl border border-slate-700/60 bg-[#0a1728] p-3"
|
||||
: "rounded-xl border border-slate-200 bg-slate-50 p-3";
|
||||
}
|
||||
|
||||
function formatValue(value: number | null, unit: string) {
|
||||
if (value === null || Number.isNaN(value)) return "--";
|
||||
return `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
function formatSignedValue(value: number | null, unit: string) {
|
||||
if (value === null || Number.isNaN(value)) return "--";
|
||||
|
||||
const prefix = value >= 0 ? "+" : "";
|
||||
return `${prefix}${value.toFixed(1)}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||
import type { HistorianPoint } from "../components/MeteoHistoryModal";
|
||||
|
||||
const BACKEND_URL = "http://localhost:18450";
|
||||
|
||||
export function useMeteoHistory(sensor: ModuleSensorResponse | null) {
|
||||
const [hours, setHours] = useState(1);
|
||||
const [points, setPoints] = useState<HistorianPoint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sensor) {
|
||||
setPoints([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const sensorKey = sensor.key;
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - hours * 60 * 60 * 1000);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
key: `meteo.${sensorKey}`,
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load history");
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as HistorianPoint[];
|
||||
setPoints(payload);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
console.error("Failed to load meteo history", error);
|
||||
setPoints([]);
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [sensor?.key, hours]);
|
||||
|
||||
return {
|
||||
points,
|
||||
loading,
|
||||
hours,
|
||||
setHours,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Client } from "@stomp/stompjs";
|
||||
import type { MeteoModuleResponse } from "../../../types/meteo";
|
||||
|
||||
const WS_URL = "ws://localhost:18450/ws";
|
||||
const TOPIC = "/topic/modules/meteo/latest";
|
||||
|
||||
export function useMeteoModuleStream() {
|
||||
const [module, setModule] = useState<MeteoModuleResponse | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [lastTimestamp, setLastTimestamp] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const client = new Client({
|
||||
brokerURL: WS_URL,
|
||||
reconnectDelay: 3000,
|
||||
|
||||
onConnect: () => {
|
||||
setConnected(true);
|
||||
|
||||
client.subscribe(TOPIC, (message) => {
|
||||
const payload = JSON.parse(message.body) as MeteoModuleResponse;
|
||||
|
||||
setModule(payload);
|
||||
setLastTimestamp(payload.timestamp);
|
||||
});
|
||||
},
|
||||
|
||||
onWebSocketClose: () => {
|
||||
setConnected(false);
|
||||
},
|
||||
|
||||
onStompError: (frame) => {
|
||||
console.error("Meteo module STOMP error", frame);
|
||||
setConnected(false);
|
||||
},
|
||||
});
|
||||
|
||||
client.activate();
|
||||
|
||||
return () => {
|
||||
client.deactivate();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
module,
|
||||
sensors: module?.sensors ?? [],
|
||||
sensorCount: module?.sensorCount ?? 0,
|
||||
connected,
|
||||
lastTimestamp,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ChartNoAxesColumnIncreasing,
|
||||
Table2,
|
||||
CloudRain,
|
||||
Compass,
|
||||
Droplets,
|
||||
MoreHorizontal,
|
||||
Radio,
|
||||
Sun,
|
||||
Thermometer,
|
||||
Wind,
|
||||
Wifi,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream";
|
||||
import { MeteoHistoryModal } from "../components/MeteoHistoryModal";
|
||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||
import { useMeteoHistory } from "../hooks/useMeteoHistory";
|
||||
|
||||
type MeteoPageProps = {
|
||||
theme: "dark" | "light";
|
||||
};
|
||||
|
||||
type HistoryMap = Record<string, number[]>;
|
||||
|
||||
const MAX_HISTORY_POINTS = 34;
|
||||
|
||||
export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
const { sensors, sensorCount, connected, lastTimestamp } =
|
||||
useMeteoModuleStream();
|
||||
|
||||
const [selectedSensor, setSelectedSensor] =
|
||||
useState<ModuleSensorResponse | null>(null);
|
||||
|
||||
const [history, setHistory] = useState<HistoryMap>({});
|
||||
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const temperature = findSensor(sensors, "temperatura.exterior");
|
||||
const humidity = findSensor(sensors, "humidade.exterior");
|
||||
const windDirection = findSensor(sensors, "direcao.vento");
|
||||
|
||||
const windSpeed = maxSensor(
|
||||
sensors.filter((sensor) => sensor.key.startsWith("velocidade.vento.")),
|
||||
);
|
||||
|
||||
const radiation = maxSensor(
|
||||
sensors.filter((sensor) => sensor.key.startsWith("radiacao.")),
|
||||
);
|
||||
|
||||
const rainSensors = sensors.filter((sensor) => sensor.key.startsWith("chuva."));
|
||||
const rainSensor =
|
||||
findSensor(sensors, "chuva.atual") ??
|
||||
findSensor(sensors, "chuva.instantanea") ??
|
||||
findSensor(sensors, "chuva.intensidade") ??
|
||||
maxSensor(rainSensors);
|
||||
const rainValue = numericValue(rainSensor);
|
||||
const isRaining = rainValue !== null && rainValue > 0;
|
||||
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [selectedTable, setSelectedTable] = useState<{
|
||||
title: string;
|
||||
sensors: ModuleSensorResponse[];
|
||||
} | null>(null);
|
||||
|
||||
const meteoHistory = useMeteoHistory(selectedSensor);
|
||||
|
||||
useEffect(() => {
|
||||
const samples: Array<[string, number | null]> = [
|
||||
["temperatura.exterior", numericValue(temperature)],
|
||||
["humidade.exterior", numericValue(humidity)],
|
||||
["vento.velocidade", numericValue(windSpeed)],
|
||||
["radiacao.solar", numericValue(radiation)],
|
||||
["chuva.total", numericValue(rainSensor)],
|
||||
];
|
||||
|
||||
setHistory((current) => {
|
||||
const next = { ...current };
|
||||
|
||||
for (const [key, value] of samples) {
|
||||
if (value === null || Number.isNaN(value)) continue;
|
||||
|
||||
const previous = next[key] ?? [];
|
||||
const last = previous[previous.length - 1];
|
||||
|
||||
if (last === value && previous.length > 1) continue;
|
||||
|
||||
next[key] = [...previous, value].slice(-MAX_HISTORY_POINTS);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, [
|
||||
temperature?.value,
|
||||
humidity?.value,
|
||||
windSpeed?.value,
|
||||
radiation?.value,
|
||||
rainSensor?.value,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? "rounded-[30px] border border-white/10 bg-[#071827] p-4 shadow-[0_24px_70px_rgba(0,0,0,0.28)]"
|
||||
: "rounded-[30px] border border-slate-200 bg-slate-50 p-4 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_372px_1fr]">
|
||||
<div className="grid gap-4">
|
||||
<MetricTile
|
||||
theme={theme}
|
||||
title="Temperatura"
|
||||
subtitle="Temperatura exterior"
|
||||
sensor={temperature}
|
||||
icon={<Thermometer className="h-5 w-5" />}
|
||||
accent="amber"
|
||||
status={temperatureBadge(temperature)}
|
||||
values={history["temperatura.exterior"]}
|
||||
menuOpen={openMenu === "temperature"}
|
||||
onMenuToggle={() => setOpenMenu(openMenu === "temperature" ? null : "temperature")}
|
||||
actions={[
|
||||
{
|
||||
label: "Ver gráfico",
|
||||
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
setSelectedSensor(temperature ?? null);
|
||||
setOpenMenu(null);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<MetricTile
|
||||
theme={theme}
|
||||
title="Humidade"
|
||||
subtitle="Humidade relativa"
|
||||
sensor={humidity}
|
||||
icon={<Droplets className="h-5 w-5" />}
|
||||
accent="blue"
|
||||
status={humidityBadge(humidity)}
|
||||
values={history["humidade.exterior"]}
|
||||
menuOpen={openMenu === "humidity"}
|
||||
onMenuToggle={() =>
|
||||
setOpenMenu(openMenu === "humidity" ? null : "humidity")
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Ver gráfico",
|
||||
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
setSelectedSensor(humidity ?? null);
|
||||
setOpenMenu(null);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<MetricTile
|
||||
theme={theme}
|
||||
title="Precipitação"
|
||||
subtitle="Precipitação atual"
|
||||
sensor={rainSensor}
|
||||
icon={<CloudRain className="h-5 w-5" />}
|
||||
accent="emerald"
|
||||
status={isRaining ? "A chover" : "Sem chuva"}
|
||||
values={history["chuva.total"]}
|
||||
menuOpen={openMenu === "rain"}
|
||||
onMenuToggle={() =>
|
||||
setOpenMenu(openMenu === "rain" ? null : "rain")
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Ver gráfico",
|
||||
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
setSelectedSensor(rainSensor ?? null);
|
||||
setOpenMenu(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Ver acumulado",
|
||||
icon: <Table2 className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
setSelectedTable({
|
||||
title: "Precipitação acumulada",
|
||||
sensors: rainSensors,
|
||||
});
|
||||
|
||||
setOpenMenu(null);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CompassPanel
|
||||
theme={theme}
|
||||
direction={numericValue(windDirection)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<MetricTile
|
||||
theme={theme}
|
||||
title="Vento"
|
||||
subtitle="Velocidade do vento"
|
||||
sensor={windSpeed}
|
||||
icon={<Wind className="h-5 w-5" />}
|
||||
accent="cyan"
|
||||
status={windBadge(windSpeed)}
|
||||
values={history["vento.velocidade"]}
|
||||
menuOpen={openMenu === "wind"}
|
||||
onMenuToggle={() =>
|
||||
setOpenMenu(openMenu === "wind" ? null : "wind")
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Ver gráfico",
|
||||
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
setSelectedSensor(windSpeed ?? null);
|
||||
setOpenMenu(null);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<MetricTile
|
||||
theme={theme}
|
||||
title="Radiação solar"
|
||||
subtitle="Radiação instantânea"
|
||||
sensor={radiation}
|
||||
icon={<Sun className="h-5 w-5" />}
|
||||
accent="amber"
|
||||
status={radiationBadge(radiation)}
|
||||
values={history["radiacao.solar"]}
|
||||
menuOpen={openMenu === "radiation"}
|
||||
onMenuToggle={() =>
|
||||
setOpenMenu(openMenu === "radiation" ? null : "radiation")
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Ver gráfico",
|
||||
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
setSelectedSensor(radiation ?? null);
|
||||
setOpenMenu(null);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Ver acumulado",
|
||||
icon: <Table2 className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
setSelectedTable({
|
||||
title: "Radiação solar acumulada",
|
||||
sensors: sensors.filter((sensor) =>
|
||||
sensor.key.startsWith("radiacao."),
|
||||
),
|
||||
});
|
||||
|
||||
setOpenMenu(null);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<StatusTile
|
||||
theme={theme}
|
||||
connected={connected}
|
||||
sensorCount={sensorCount}
|
||||
lastTimestamp={lastTimestamp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<MeteoHistoryModal
|
||||
sensor={selectedSensor}
|
||||
theme={theme}
|
||||
points={meteoHistory.points}
|
||||
loading={meteoHistory.loading}
|
||||
hours={meteoHistory.hours}
|
||||
onHoursChange={meteoHistory.setHours}
|
||||
onClose={() => setSelectedSensor(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTile({
|
||||
theme,
|
||||
title,
|
||||
subtitle,
|
||||
sensor,
|
||||
customValue,
|
||||
customUnit,
|
||||
icon,
|
||||
accent,
|
||||
status,
|
||||
values,
|
||||
menuOpen,
|
||||
onMenuToggle,
|
||||
actions,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
sensor?: ModuleSensorResponse;
|
||||
customValue?: string;
|
||||
customUnit?: string;
|
||||
icon: ReactNode;
|
||||
accent: "amber" | "blue" | "cyan" | "emerald";
|
||||
status: string;
|
||||
values?: number[];
|
||||
menuOpen: boolean;
|
||||
onMenuToggle: () => void;
|
||||
actions: Array<{
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
const colors = accentColors(accent);
|
||||
const value = customValue ?? formatValue(sensor);
|
||||
const unit = customUnit ?? sensor?.unit;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "group relative min-h-[178px] overflow-visible rounded-[22px] border border-[#24384d] bg-[linear-gradient(135deg,#10263b_0%,#0b1d31_48%,#081827_100%)] p-6 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_34px_rgba(0,0,0,0.22)] transition hover:border-[#31506b]"
|
||||
: "group relative min-h-[178px] overflow-visible rounded-[22px] border border-slate-200 bg-white p-6 text-left shadow-sm transition hover:border-slate-300"
|
||||
}
|
||||
>
|
||||
<div className="relative z-20 flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`grid h-12 w-12 place-items-center rounded-2xl border ${isDark
|
||||
? "border-[#263e56] bg-[#132b43]"
|
||||
: "border-slate-200 bg-slate-50"
|
||||
} ${colors.text}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2
|
||||
className={
|
||||
isDark
|
||||
? "text-[15px] font-bold text-white"
|
||||
: "text-[15px] font-bold text-slate-950"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-sm text-slate-400"
|
||||
: "mt-1 text-sm text-slate-500"
|
||||
}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMenuToggle();
|
||||
}}
|
||||
className={
|
||||
isDark
|
||||
? "rounded-lg p-1 text-slate-300 transition hover:bg-white/10 hover:text-white"
|
||||
: "rounded-lg p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute right-0 top-8 z-50 w-44 rounded-xl border border-[#24384d] bg-[#0b2035] p-1 shadow-2xl"
|
||||
: "absolute right-0 top-8 z-50 w-44 rounded-xl border border-slate-200 bg-white p-1 shadow-xl"
|
||||
}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
action.onClick();
|
||||
}}
|
||||
className={
|
||||
isDark
|
||||
? "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-semibold text-slate-200 transition hover:bg-white/10 hover:text-white"
|
||||
: "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 hover:text-slate-950"
|
||||
}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mt-7 flex items-end justify-between gap-5">
|
||||
<div>
|
||||
<div
|
||||
className={`text-[46px] font-black leading-none tracking-[-0.06em] ${colors.text}`}
|
||||
>
|
||||
{value}
|
||||
{unit && (
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "ml-2 text-sm font-black tracking-normal text-white"
|
||||
: "ml-2 text-sm font-black tracking-normal text-slate-800"
|
||||
}
|
||||
>
|
||||
{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`mt-5 inline-flex min-w-[96px] items-center justify-center rounded-xl border px-4 py-2 text-sm font-bold ${isDark
|
||||
? `${colors.badge} ${colors.text}`
|
||||
: "border-slate-200 bg-slate-50 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sparkline
|
||||
values={values}
|
||||
className="absolute bottom-8 right-7 z-0 h-[56px] w-[210px]"
|
||||
strokeClassName={colors.stroke}
|
||||
glowClassName={colors.dot}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompassPanel({
|
||||
theme,
|
||||
direction,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
direction: number | null;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
const angle = direction ?? 0;
|
||||
const cardinal = directionName(direction);
|
||||
const degrees = direction !== null ? Math.round(direction) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "relative flex min-h-[560px] flex-col overflow-hidden rounded-[22px] border border-[#24384d] bg-[linear-gradient(180deg,#0d2236_0%,#081827_100%)] px-6 py-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_34px_rgba(0,0,0,0.22)]"
|
||||
: "relative flex min-h-[560px] flex-col overflow-hidden rounded-[22px] border border-slate-200 bg-white px-6 py-5 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<p className={isDark ? "text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400" : "text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500"}>
|
||||
Direção do vento
|
||||
</p>
|
||||
|
||||
<h2 className={isDark ? "mt-3 text-[34px] font-black leading-none tracking-[-0.05em] text-white" : "mt-3 text-[34px] font-black leading-none tracking-[-0.05em] text-slate-950"}>
|
||||
{cardinal}
|
||||
</h2>
|
||||
|
||||
<div className="mt-5 grid w-full grid-cols-2 gap-3">
|
||||
<div className={isDark ? "rounded-[18px] border border-[#24384d] bg-[#0b2035] px-4 py-3" : "rounded-[18px] border border-slate-200 bg-slate-50 px-4 py-3"}>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">
|
||||
Graus
|
||||
</p>
|
||||
<p className={isDark ? "mt-1 text-[20px] font-black text-white" : "mt-1 text-[20px] font-black text-slate-950"}>
|
||||
{degrees !== null ? `${degrees}°` : "--"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={isDark ? "rounded-[18px] border border-[#24384d] bg-[#0b2035] px-4 py-3" : "rounded-[18px] border border-slate-200 bg-slate-50 px-4 py-3"}>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">
|
||||
Quadrante
|
||||
</p>
|
||||
<p className="mt-1 text-[20px] font-black text-cyan-300">
|
||||
{directionQuadrant(direction)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center pt-2">
|
||||
<div className="relative h-[320px] w-[320px]">
|
||||
<div className={isDark ? "absolute inset-0 rounded-full border border-[#20364d] bg-[#071827]" : "absolute inset-0 rounded-full border border-slate-200 bg-slate-50"} />
|
||||
<div className={isDark ? "absolute inset-5 rounded-full border border-[#213b55]" : "absolute inset-5 rounded-full border border-slate-200"} />
|
||||
<div className={isDark ? "absolute inset-[74px] rounded-full border border-cyan-400/35 bg-[#0a1d30]" : "absolute inset-[74px] rounded-full border border-cyan-500/30 bg-white"} />
|
||||
|
||||
{Array.from({ length: 72 }).map((_, index) => {
|
||||
const major = index % 6 === 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={`absolute left-1/2 top-1/2 w-px origin-[50%_149px] ${major ? "h-3" : "h-1.5"
|
||||
} ${isDark ? "bg-slate-500/50" : "bg-slate-400/60"}`}
|
||||
style={{
|
||||
transform: `translate(-50%, -149px) rotate(${index * 5}deg)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<CompassLabel label="N" className="left-1/2 top-[18px] -translate-x-1/2" />
|
||||
<CompassLabel label="S" className="bottom-[18px] left-1/2 -translate-x-1/2" />
|
||||
<CompassLabel label="W" className="left-[22px] top-1/2 -translate-y-1/2" />
|
||||
<CompassLabel label="E" className="right-[22px] top-1/2 -translate-y-1/2" />
|
||||
|
||||
<div className={isDark ? "absolute inset-[108px] rounded-full bg-[#0b2035] shadow-[0_0_24px_rgba(34,211,238,0.08)]" : "absolute inset-[108px] rounded-full bg-white shadow-sm"} />
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "relative h-24 w-24 rounded-full border border-cyan-400/20 bg-[#0b2035]"
|
||||
: "relative h-24 w-24 rounded-full border border-cyan-500/20 bg-white"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-[124px] w-[14px] origin-bottom rounded-full bg-cyan-300/90 shadow-[0_0_8px_rgba(34,211,238,0.28)]"
|
||||
style={{
|
||||
clipPath: "polygon(50% 0%, 100% 100%, 50% 88%, 0% 100%)",
|
||||
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-[116px] w-[5px] origin-bottom rounded-full bg-white/55"
|
||||
style={{
|
||||
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-1/2 top-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border border-cyan-300/30 bg-[#0d263d]"
|
||||
: "absolute left-1/2 top-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border border-cyan-500/30 bg-white"
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompassLabel({
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`absolute flex h-5 w-5 items-center justify-center text-[13px] font-black leading-none tracking-[0.02em] text-slate-200 ${className}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function directionQuadrant(direction: number | null) {
|
||||
if (direction === null) return "--";
|
||||
if (direction >= 315 || direction < 45) return "Norte";
|
||||
if (direction >= 45 && direction < 135) return "Este";
|
||||
if (direction >= 135 && direction < 225) return "Sul";
|
||||
return "Oeste";
|
||||
}
|
||||
|
||||
function StatusTile({
|
||||
theme,
|
||||
connected,
|
||||
sensorCount,
|
||||
lastTimestamp,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
connected: boolean;
|
||||
sensorCount: number;
|
||||
lastTimestamp: string | null;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "min-h-[178px] rounded-[22px] border border-[#24384d] bg-[linear-gradient(135deg,#10263b_0%,#0b1d31_48%,#081827_100%)] p-6 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_34px_rgba(0,0,0,0.22)]"
|
||||
: "min-h-[178px] rounded-[22px] border border-slate-200 bg-white p-6 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Radio className="h-5 w-5 text-emerald-300" />
|
||||
<h2 className={isDark ? "text-[15px] font-bold text-white" : "text-[15px] font-bold text-slate-950"}>
|
||||
Estado do módulo
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Ligação"
|
||||
value={connected ? "Online" : "Offline"}
|
||||
online={connected}
|
||||
/>
|
||||
<StatusRow theme={theme} label="Sensores" value={String(sensorCount)} />
|
||||
<StatusRow
|
||||
theme={theme}
|
||||
label="Atualização"
|
||||
value={lastTimestamp ? formatTime(lastTimestamp) : "--"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({
|
||||
theme,
|
||||
label,
|
||||
value,
|
||||
online,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
label: string;
|
||||
value: string;
|
||||
online?: boolean;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className={isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-3">
|
||||
{online !== undefined && (
|
||||
<Wifi className={online ? "h-4 w-4 text-emerald-300" : "h-4 w-4 text-red-300"} />
|
||||
)}
|
||||
<span className={online ? "text-sm font-semibold text-emerald-300" : isDark ? "text-sm font-semibold text-white" : "text-sm font-semibold text-slate-950"}>
|
||||
{value}
|
||||
</span>
|
||||
<ChevronRight className={isDark ? "h-4 w-4 text-slate-500" : "h-4 w-4 text-slate-400"} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
values,
|
||||
className,
|
||||
strokeClassName,
|
||||
glowClassName,
|
||||
}: {
|
||||
values?: number[];
|
||||
className?: string;
|
||||
strokeClassName: string;
|
||||
glowClassName: string;
|
||||
}) {
|
||||
if (!values || values.length < 2) return null;
|
||||
|
||||
const width = 210;
|
||||
const height = 56;
|
||||
const padding = 8;
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = values.map((value, index) => {
|
||||
const x = padding + (index / (values.length - 1)) * (width - padding * 2);
|
||||
const y = padding + (1 - (value - min) / range) * (height - padding * 2);
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const last = points[points.length - 1].split(",").map(Number);
|
||||
|
||||
return (
|
||||
<svg className={`pointer-events-none opacity-75 ${className}`} viewBox={`0 0 ${width} ${height}`} fill="none">
|
||||
<polyline
|
||||
points={points.join(" ")}
|
||||
className={strokeClassName}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx={last[0]} cy={last[1]} r="4" className={glowClassName} />
|
||||
<circle cx={last[0]} cy={last[1]} r="7" className={`${glowClassName} opacity-20`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function findSensor(sensors: ModuleSensorResponse[], key: string) {
|
||||
return sensors.find((sensor) => sensor.key === key);
|
||||
}
|
||||
|
||||
function numericValue(sensor?: ModuleSensorResponse) {
|
||||
return typeof sensor?.value === "number" ? sensor.value : null;
|
||||
}
|
||||
|
||||
function maxSensor(sensors: ModuleSensorResponse[]) {
|
||||
return sensors
|
||||
.filter((sensor) => typeof sensor.value === "number")
|
||||
.sort((a, b) => Number(b.value) - Number(a.value))[0];
|
||||
}
|
||||
|
||||
function formatValue(sensor?: ModuleSensorResponse) {
|
||||
if (!sensor) return "--";
|
||||
|
||||
if (typeof sensor.value === "number") {
|
||||
return Number.isInteger(sensor.value)
|
||||
? String(sensor.value)
|
||||
: sensor.value.toFixed(1);
|
||||
}
|
||||
|
||||
if (typeof sensor.value === "boolean") {
|
||||
return sensor.value ? "On" : "Off";
|
||||
}
|
||||
|
||||
if (sensor.value === null || sensor.value === undefined) {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return String(sensor.value);
|
||||
}
|
||||
|
||||
function directionName(direction: number | null) {
|
||||
if (direction === null) return "--";
|
||||
|
||||
const labels = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
const index = Math.round(direction / 45) % 8;
|
||||
|
||||
return labels[index];
|
||||
}
|
||||
|
||||
function temperatureBadge(sensor?: ModuleSensorResponse) {
|
||||
const value = numericValue(sensor);
|
||||
if (value === null) return "Sem dados";
|
||||
if (value >= 30) return "Quente";
|
||||
if (value <= 10) return "Frio";
|
||||
return "Normal";
|
||||
}
|
||||
|
||||
function humidityBadge(sensor?: ModuleSensorResponse) {
|
||||
const value = numericValue(sensor);
|
||||
if (value === null) return "Sem dados";
|
||||
if (value >= 80) return "Alta";
|
||||
if (value <= 35) return "Baixa";
|
||||
return "Normal";
|
||||
}
|
||||
|
||||
function windBadge(sensor?: ModuleSensorResponse) {
|
||||
const value = numericValue(sensor);
|
||||
if (value === null) return "Sem dados";
|
||||
if (value >= 30) return "Forte";
|
||||
if (value >= 10) return "Moderado";
|
||||
return "Fraco";
|
||||
}
|
||||
|
||||
function radiationBadge(sensor?: ModuleSensorResponse) {
|
||||
const value = numericValue(sensor);
|
||||
if (value === null) return "Sem dados";
|
||||
if (value >= 800) return "Alta";
|
||||
if (value >= 400) return "Média";
|
||||
return "Baixa";
|
||||
}
|
||||
|
||||
function accentColors(accent: "amber" | "blue" | "cyan" | "emerald") {
|
||||
switch (accent) {
|
||||
case "amber":
|
||||
return {
|
||||
text: "text-amber-300",
|
||||
stroke: "stroke-amber-400",
|
||||
dot: "fill-amber-400",
|
||||
badge: "border-amber-300/20 bg-amber-400/10",
|
||||
};
|
||||
case "blue":
|
||||
return {
|
||||
text: "text-sky-300",
|
||||
stroke: "stroke-sky-400",
|
||||
dot: "fill-sky-400",
|
||||
badge: "border-sky-300/20 bg-sky-400/10",
|
||||
};
|
||||
case "cyan":
|
||||
return {
|
||||
text: "text-cyan-300",
|
||||
stroke: "stroke-cyan-400",
|
||||
dot: "fill-cyan-400",
|
||||
badge: "border-cyan-300/20 bg-cyan-400/10",
|
||||
};
|
||||
case "emerald":
|
||||
return {
|
||||
text: "text-emerald-300",
|
||||
stroke: "stroke-emerald-400",
|
||||
dot: "fill-emerald-400",
|
||||
badge: "border-emerald-300/20 bg-emerald-400/10",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string) {
|
||||
return new Date(timestamp).toLocaleTimeString("pt-PT", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type ModuleSensorResponse = {
|
||||
sensorId: number;
|
||||
name: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
unit: string | null;
|
||||
modbusAddress: number;
|
||||
bitOffset: number | null;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type MeteoModuleResponse = {
|
||||
timestamp: string;
|
||||
sensorCount: number;
|
||||
sensors: ModuleSensorResponse[];
|
||||
};
|
||||
Reference in New Issue
Block a user