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 { AppShell } from "../components/layout/AppShell";
|
||||||
import { DashboardPage } from "../features/dashboard/pages/DashboardPage";
|
import { DashboardPage } from "../features/dashboard/pages/DashboardPage";
|
||||||
|
import { MeteoPage } from "../features/meteo/pages/MeteoPage";
|
||||||
|
|
||||||
|
export type AppPage = "dashboard" | "meteo";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell activePage={activePage} onNavigate={setActivePage}>
|
||||||
{({ theme}) => (
|
{({ theme }) =>
|
||||||
<DashboardPage theme={theme} />
|
activePage === "meteo" ? (
|
||||||
)}
|
<MeteoPage theme={theme} />
|
||||||
|
) : (
|
||||||
|
<DashboardPage theme={theme} />
|
||||||
|
)
|
||||||
|
}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useNotifications } from "../../features/notifications/hooks/useNotifica
|
|||||||
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser";
|
||||||
import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig";
|
import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig";
|
||||||
import type { TelemetrySnapshot } from "../../types/telemetry";
|
import type { TelemetrySnapshot } from "../../types/telemetry";
|
||||||
|
import type { AppPage } from "../../app/App";
|
||||||
|
|
||||||
type AppShellRenderProps = {
|
type AppShellRenderProps = {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
@@ -14,14 +15,16 @@ type AppShellRenderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
|
activePage: AppPage;
|
||||||
|
onNavigate: (page: AppPage) => void;
|
||||||
children: (props: AppShellRenderProps) => ReactNode;
|
children: (props: AppShellRenderProps) => ReactNode;
|
||||||
};
|
};
|
||||||
|
export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
||||||
export function AppShell({ children }: AppShellProps) {
|
|
||||||
const telemetry = useTelemetryStream();
|
const telemetry = useTelemetryStream();
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const runtime = useRuntimeConfig();
|
const runtime = useRuntimeConfig();
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
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="flex h-full overflow-hidden">
|
||||||
<div className="relative h-full shrink-0 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 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>
|
</div>
|
||||||
|
|
||||||
@@ -52,6 +61,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
notificationCount={notifications.unreadCount}
|
notificationCount={notifications.unreadCount}
|
||||||
userInitials={currentUser.initials}
|
userInitials={currentUser.initials}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
activePage={activePage}
|
||||||
onToggleTheme={toggleTheme}
|
onToggleTheme={toggleTheme}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { AppPage } from "../../app/App";
|
||||||
|
|
||||||
type TopBarProps = {
|
type TopBarProps = {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -20,6 +21,7 @@ type TopBarProps = {
|
|||||||
notificationCount: number;
|
notificationCount: number;
|
||||||
userInitials: string;
|
userInitials: string;
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
|
activePage: AppPage | null;
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ export function TopBar({
|
|||||||
notificationCount,
|
notificationCount,
|
||||||
userInitials,
|
userInitials,
|
||||||
theme,
|
theme,
|
||||||
|
activePage,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
@@ -93,7 +96,7 @@ export function TopBar({
|
|||||||
: "text-[28px] font-bold tracking-tight text-[#162434]"
|
: "text-[28px] font-bold tracking-tight text-[#162434]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Painel Principal
|
{pageTitle(activePage)}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,8 +221,8 @@ export function TopBar({
|
|||||||
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`h-4 w-4 transition-transform ${isDark
|
className={`h-4 w-4 transition-transform ${isDark
|
||||||
? "text-[#8FA3B8]"
|
? "text-[#8FA3B8]"
|
||||||
: "text-[#607284]"
|
: "text-[#607284]"
|
||||||
} ${userMenuOpen ? "rotate-180" : ""}`}
|
} ${userMenuOpen ? "rotate-180" : ""}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -307,3 +310,16 @@ export function TopBar({
|
|||||||
</header>
|
</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 {
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
CloudSun,
|
CloudSun,
|
||||||
Droplet,
|
Droplet,
|
||||||
Home,
|
Home,
|
||||||
@@ -8,70 +10,99 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import logo from "../../assets/logo.png";
|
import logo from "../../assets/logo.png";
|
||||||
|
import type { AppPage } from "../../app/App";
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
|
activePage: AppPage;
|
||||||
|
collapsed: boolean;
|
||||||
|
onNavigate: (page: AppPage) => void;
|
||||||
|
onToggleCollapsed: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems: {
|
||||||
{ label: "Painel Principal", icon: Home, active: true },
|
label: string;
|
||||||
{ label: "Meteorologia", icon: CloudSun },
|
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: "Consola (VNC)", icon: TabletSmartphone },
|
||||||
{ label: "Rega", icon: Droplet },
|
{ label: "Rega", icon: Droplet },
|
||||||
{ label: "Clima", icon: Wind },
|
{ label: "Clima", icon: Wind },
|
||||||
{ label: "Configurações", icon: Settings },
|
{ label: "Configurações", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ theme }: SidebarProps) {
|
export function Sidebar({
|
||||||
|
theme,
|
||||||
|
activePage,
|
||||||
|
collapsed,
|
||||||
|
onNavigate,
|
||||||
|
onToggleCollapsed,
|
||||||
|
}: SidebarProps) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={
|
className={
|
||||||
isDark
|
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)]"
|
? `${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`
|
||||||
: "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-[#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
|
<img
|
||||||
src={logo}
|
src={logo}
|
||||||
alt="LitoralRegas"
|
alt="LitoralRegas"
|
||||||
className="h-12 w-12 shrink-0 object-contain"
|
className="h-12 w-12 shrink-0 object-contain"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="min-w-0">
|
{!collapsed && (
|
||||||
<div
|
<div className="min-w-0">
|
||||||
className={
|
<div
|
||||||
isDark
|
className={
|
||||||
? "truncate text-[16px] font-bold tracking-wide text-white"
|
isDark
|
||||||
: "truncate text-[16px] font-bold tracking-wide text-[#162434]"
|
? "truncate text-[16px] font-bold tracking-wide text-white"
|
||||||
}
|
: "truncate text-[16px] font-bold tracking-wide text-[#162434]"
|
||||||
>
|
}
|
||||||
LITORAL CENTRAL
|
>
|
||||||
</div>
|
LITORAL CENTRAL
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#8FA3B8]"
|
? "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#8FA3B8]"
|
||||||
: "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#607284]"
|
: "mt-0.5 text-[10px] uppercase tracking-[0.18em] text-[#607284]"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
OPERAÇÕES AGRÍCOLAS
|
OPERAÇÕES AGRÍCOLAS
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-2">
|
<nav className="space-y-2">
|
||||||
{navigationItems.map((item) => {
|
{navigationItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
const active = activePage === item.page;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
|
onClick={() => onNavigate(item.page)}
|
||||||
|
title={collapsed ? item.label : undefined}
|
||||||
className={
|
className={
|
||||||
item.active
|
active
|
||||||
? isDark
|
? 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-[#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]"
|
: "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]"
|
: "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" />
|
<Icon className="h-5 w-5 shrink-0" />
|
||||||
{item.label}
|
{!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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div
|
<button
|
||||||
|
onClick={onToggleCollapsed}
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? "mt-auto px-4 text-sm text-slate-400"
|
? "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 px-4 text-sm text-[#607284]"
|
: "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
|
{collapsed ? (
|
||||||
</div>
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
<span>Recolher menu</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</aside>
|
</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