Add professional historian modal with realtime analytics

This commit is contained in:
litoral05
2026-05-22 17:08:22 +01:00
parent a30d41d031
commit 6277653fed
9 changed files with 1705 additions and 44 deletions
+13 -3
View File
@@ -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}) => (
<AppShell activePage={activePage} onNavigate={setActivePage}>
{({ theme }) =>
activePage === "meteo" ? (
<MeteoPage theme={theme} />
) : (
<DashboardPage theme={theme} />
)}
)
}
</AppShell>
);
}
+13 -3
View File
@@ -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}
/>
+17 -1
View File
@@ -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>
@@ -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";
}
}
+74 -15
View File
@@ -1,4 +1,6 @@
import {
ChevronLeft,
ChevronRight,
CloudSun,
Droplet,
Home,
@@ -8,38 +10,63 @@ 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={
collapsed
? "mb-10 flex items-center justify-center"
: "mb-10 flex items-center gap-3 px-2"
}
>
<div className="mb-10 flex items-center gap-3 px-2">
<img
src={logo}
alt="LitoralRegas"
className="h-12 w-12 shrink-0 object-contain"
/>
{!collapsed && (
<div className="min-w-0">
<div
className={
@@ -61,17 +88,21 @@ export function Sidebar({ theme }: SidebarProps) {
OPERAÇÕES AGRÍCOLAS
</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,
};
}
+837
View File
@@ -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",
});
}
+16
View File
@@ -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[];
};