From 6277653fed56463ecab0cb947bea064f6b2c66c8 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Fri, 22 May 2026 17:08:22 +0100 Subject: [PATCH] Add professional historian modal with realtime analytics --- src/app/App.tsx | 18 +- src/components/layout/AppShell.tsx | 16 +- src/components/layout/TopBar.tsx | 22 +- src/components/navigation/Sidebar.tsx | 127 ++- .../meteo/components/MeteoHistoryModal.tsx | 588 ++++++++++++ src/features/meteo/hooks/useMeteoHistory.ts | 72 ++ .../meteo/hooks/useMeteoModuleStream.ts | 53 ++ src/features/meteo/pages/MeteoPage.tsx | 837 ++++++++++++++++++ src/types/meteo.ts | 16 + 9 files changed, 1705 insertions(+), 44 deletions(-) create mode 100644 src/features/meteo/components/MeteoHistoryModal.tsx create mode 100644 src/features/meteo/hooks/useMeteoHistory.ts create mode 100644 src/features/meteo/hooks/useMeteoModuleStream.ts create mode 100644 src/features/meteo/pages/MeteoPage.tsx create mode 100644 src/types/meteo.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index a4e2681..c5647b3 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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("dashboard"); + return ( - - {({ theme}) => ( - - )} + + {({ theme }) => + activePage === "meteo" ? ( + + ) : ( + + ) + } ); } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 85691f8..8b52d7b 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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) { >
- + setSidebarCollapsed((current) => !current)} + />
@@ -52,6 +61,7 @@ export function AppShell({ children }: AppShellProps) { notificationCount={notifications.unreadCount} userInitials={currentUser.initials} theme={theme} + activePage={activePage} onToggleTheme={toggleTheme} /> diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 5bff8b5..14c7996 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -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)}
@@ -218,8 +221,8 @@ export function TopBar({ @@ -306,4 +309,17 @@ export function TopBar({
); +} + +function pageTitle(page: AppPage | null) { + switch (page) { + case "dashboard": + return "Painel Principal"; + + case "meteo": + return "Meteorologia"; + + default: + return "Painel Principal"; + } } \ No newline at end of file diff --git a/src/components/navigation/Sidebar.tsx b/src/components/navigation/Sidebar.tsx index ffe9994..324f777 100644 --- a/src/components/navigation/Sidebar.tsx +++ b/src/components/navigation/Sidebar.tsx @@ -1,4 +1,6 @@ import { + ChevronLeft, + ChevronRight, CloudSun, Droplet, Home, @@ -8,70 +10,99 @@ import { } from "lucide-react"; import logo from "../../assets/logo.png"; +import type { AppPage } from "../../app/App"; type SidebarProps = { theme: "dark" | "light"; + activePage: AppPage; + collapsed: boolean; + onNavigate: (page: AppPage) => void; + onToggleCollapsed: () => void; }; -const navigationItems = [ - { label: "Painel Principal", icon: Home, active: true }, - { label: "Meteorologia", icon: CloudSun }, +const navigationItems: { + label: string; + page: AppPage; + icon: React.ElementType; +}[] = [ + { label: "Painel Principal", page: "dashboard", icon: Home }, + { label: "Meteorologia", page: "meteo", icon: CloudSun }, + ]; + +const disabledItems = [ { label: "Consola (VNC)", icon: TabletSmartphone }, { label: "Rega", icon: Droplet }, { label: "Clima", icon: Wind }, { label: "Configurações", icon: Settings }, ]; -export function Sidebar({ theme }: SidebarProps) { +export function Sidebar({ + theme, + activePage, + collapsed, + onNavigate, + onToggleCollapsed, +}: SidebarProps) { const isDark = theme === "dark"; return ( ); } \ No newline at end of file diff --git a/src/features/meteo/components/MeteoHistoryModal.tsx b/src/features/meteo/components/MeteoHistoryModal.tsx new file mode 100644 index 0000000..ef1d1df --- /dev/null +++ b/src/features/meteo/components/MeteoHistoryModal.tsx @@ -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(); + 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 ( +
+
+
+
+

+ Histórico +

+

{sensor.name}

+

+ Chave: meteo.{sensor.key} +

+
+ +
+ +
+
+ +
+
+
+ {RANGE_OPTIONS.map((range) => ( + + ))} + +
+ + + {intervalOpen && ( +
+ {INTERVAL_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ + + +
+ +
+ + + {sensor.name} + {unit && ` (${unit})`} + + + Média: {formatValue(stats.average, unit)} + Máx: {formatValue(stats.max, unit)} + Mín: {formatValue(stats.min, unit)} + +
+ setChartMode(chartMode === "area" ? "line" : "area")} + icon={} + /> + setZeroBaseline((value) => !value)} + icon={} + /> + setExpanded((value) => !value)} + icon={} + /> +
+
+ +
+ {loading ? ( + A carregar histórico... + ) : chartData.length === 0 ? ( + Sem dados históricos para este período. + ) : ( + + + + + + + + + + + + + + + + { + if (!active || !payload?.length) return null; + + const value = Number(payload[0].value); + + return ( +
+

{label}

+ +

+ Atual: {formatValue(value, unit)} +

+ +

+ Média: {formatValue(stats.average, unit)} +

+ +

+ Máx: {formatValue(stats.max, unit)} +

+ +

+ Mín: {formatValue(stats.min, unit)} +

+
+ ); + }} + /> + {showIndicators && stats.average !== null && ( + + )} + + {showCompareLine && stats.current !== null && ( + + )} + + {chartMode === "area" ? ( + + ) : ( + + )} +
+
+ )} +
+
+ + {showIndicators && ( +
+ + = 0} /> + + + +
+

Volume de Dados

+

{stats.count.toLocaleString("pt-PT")}

+

pontos

+ +
+ + + + + +
+
+
+ )} +
+
+
+ ); +} + +function EmptyState({ children }: { children: string }) { + return
{children}
; +} + +function IconButton({ + isDark, + active, + onClick, + icon, +}: { + isDark: boolean; + active?: boolean; + onClick?: () => void; + icon: React.ReactNode; +}) { + return ( + + ); +} + +function MetricCard({ + theme, + title, + value, + sub, + positive, +}: { + theme: "dark" | "light"; + title: string; + value: string; + sub: string; + positive?: boolean; +}) { + const isDark = theme === "dark"; + + return ( +
+

{title}

+

{value}

+

{sub}

+
+ ); +} + +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}` : ""}`; +} \ No newline at end of file diff --git a/src/features/meteo/hooks/useMeteoHistory.ts b/src/features/meteo/hooks/useMeteoHistory.ts new file mode 100644 index 0000000..2c0a85c --- /dev/null +++ b/src/features/meteo/hooks/useMeteoHistory.ts @@ -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([]); + 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, + }; +} \ No newline at end of file diff --git a/src/features/meteo/hooks/useMeteoModuleStream.ts b/src/features/meteo/hooks/useMeteoModuleStream.ts new file mode 100644 index 0000000..3a344a4 --- /dev/null +++ b/src/features/meteo/hooks/useMeteoModuleStream.ts @@ -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(null); + const [connected, setConnected] = useState(false); + const [lastTimestamp, setLastTimestamp] = useState(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, + }; +} \ No newline at end of file diff --git a/src/features/meteo/pages/MeteoPage.tsx b/src/features/meteo/pages/MeteoPage.tsx new file mode 100644 index 0000000..446e7b8 --- /dev/null +++ b/src/features/meteo/pages/MeteoPage.tsx @@ -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; + +const MAX_HISTORY_POINTS = 34; + +export function MeteoPage({ theme }: MeteoPageProps) { + const { sensors, sensorCount, connected, lastTimestamp } = + useMeteoModuleStream(); + + const [selectedSensor, setSelectedSensor] = + useState(null); + + const [history, setHistory] = useState({}); + + 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(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 ( + <> +
+
+
+
+ } + accent="amber" + status={temperatureBadge(temperature)} + values={history["temperatura.exterior"]} + menuOpen={openMenu === "temperature"} + onMenuToggle={() => setOpenMenu(openMenu === "temperature" ? null : "temperature")} + actions={[ + { + label: "Ver gráfico", + icon: , + onClick: () => { + setSelectedSensor(temperature ?? null); + setOpenMenu(null); + }, + }, + ]} + /> + + } + accent="blue" + status={humidityBadge(humidity)} + values={history["humidade.exterior"]} + menuOpen={openMenu === "humidity"} + onMenuToggle={() => + setOpenMenu(openMenu === "humidity" ? null : "humidity") + } + actions={[ + { + label: "Ver gráfico", + icon: , + onClick: () => { + setSelectedSensor(humidity ?? null); + setOpenMenu(null); + }, + }, + ]} + /> + + } + 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: , + onClick: () => { + setSelectedSensor(rainSensor ?? null); + setOpenMenu(null); + }, + }, + { + label: "Ver acumulado", + icon: , + onClick: () => { + setSelectedTable({ + title: "Precipitação acumulada", + sensors: rainSensors, + }); + + setOpenMenu(null); + }, + }, + ]} + /> +
+ + + +
+ } + accent="cyan" + status={windBadge(windSpeed)} + values={history["vento.velocidade"]} + menuOpen={openMenu === "wind"} + onMenuToggle={() => + setOpenMenu(openMenu === "wind" ? null : "wind") + } + actions={[ + { + label: "Ver gráfico", + icon: , + onClick: () => { + setSelectedSensor(windSpeed ?? null); + setOpenMenu(null); + }, + }, + ]} + /> + + } + accent="amber" + status={radiationBadge(radiation)} + values={history["radiacao.solar"]} + menuOpen={openMenu === "radiation"} + onMenuToggle={() => + setOpenMenu(openMenu === "radiation" ? null : "radiation") + } + actions={[ + { + label: "Ver gráfico", + icon: , + onClick: () => { + setSelectedSensor(radiation ?? null); + setOpenMenu(null); + }, + }, + { + label: "Ver acumulado", + icon: , + onClick: () => { + setSelectedTable({ + title: "Radiação solar acumulada", + sensors: sensors.filter((sensor) => + sensor.key.startsWith("radiacao."), + ), + }); + + setOpenMenu(null); + }, + }, + ]} + /> + + +
+
+
+
+ + 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 ( +
+
+
+
+ {icon} +
+ +
+

+ {title} +

+

+ {subtitle} +

+
+
+ +
+ + + {menuOpen && ( +
+ {actions.map((action) => ( + + ))} +
+ )} +
+
+ +
+
+
+ {value} + {unit && ( + + {unit} + + )} +
+ + + {status} + +
+
+ + +
+ ); +} + +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 ( +
+
+

+ Direção do vento +

+ +

+ {cardinal} +

+ +
+
+

+ Graus +

+

+ {degrees !== null ? `${degrees}°` : "--"} +

+
+ +
+

+ Quadrante +

+

+ {directionQuadrant(direction)} +

+
+
+
+ +
+
+
+
+
+ + {Array.from({ length: 72 }).map((_, index) => { + const major = index % 6 === 0; + + return ( + + ); + })} + + + + + + +
+ +
+
+
+ +
+ +
+ +
+
+
+
+
+
+ ); +} + +function CompassLabel({ + label, + className, +}: { + label: string; + className: string; +}) { + return ( + + {label} + + ); +} + +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 ( +
+
+ +

+ Estado do módulo +

+
+ +
+ + + +
+
+ ); +} + +function StatusRow({ + theme, + label, + value, + online, +}: { + theme: "dark" | "light"; + label: string; + value: string; + online?: boolean; +}) { + const isDark = theme === "dark"; + + return ( +
+ + {label} + + + + {online !== undefined && ( + + )} + + {value} + + + +
+ ); +} + +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 ( + + + + + + ); +} + +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", + }); +} \ No newline at end of file diff --git a/src/types/meteo.ts b/src/types/meteo.ts new file mode 100644 index 0000000..65f6b6e --- /dev/null +++ b/src/types/meteo.ts @@ -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[]; +}; \ No newline at end of file