Refactor forecast and historian-driven meteo dashboard
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main", "chart-*"],
|
||||
"description": "Capability for the main and chart windows",
|
||||
"windows": [
|
||||
"main",
|
||||
"chart-*",
|
||||
"maincharts-*",
|
||||
"climatecharts-*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
@@ -19,6 +24,9 @@
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
|
||||
"core:window:allow-start-dragging"
|
||||
"core:window:allow-start-dragging",
|
||||
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-emit"
|
||||
]
|
||||
}
|
||||
@@ -9,24 +9,24 @@ import { ConsolePage } from "../features/console/pages/ConsolePage";
|
||||
import { MainChartsPage } from "../features/maincharts/pages/MainChartsPage";
|
||||
import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPage";
|
||||
import { SettingsPage } from "../features/settings/pages/SettingsPage";
|
||||
import SynopticPage from "../features/synoptic/pages/SynopticPage";
|
||||
|
||||
export type AppPage =
|
||||
| "dashboard"
|
||||
| "meteo"
|
||||
| "console"
|
||||
| "maincharts"
|
||||
| "synoptic"
|
||||
| "settings"
|
||||
| "climate"
|
||||
| "climateCharts"
|
||||
| "climateLighting"
|
||||
| "climateVentilation"
|
||||
| "climateSynoptic"
|
||||
| "irrigation"
|
||||
| "irrigationCharts"
|
||||
| "irrigationFilters"
|
||||
| "irrigationConsumption"
|
||||
| "irrigationDrainage"
|
||||
| "irrigationSynoptic";
|
||||
|
||||
function App() {
|
||||
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
||||
@@ -59,6 +59,10 @@ function App() {
|
||||
return <SettingsPage theme={theme} />;
|
||||
}
|
||||
|
||||
if (activePage === "synoptic") {
|
||||
return <SynopticPage theme={theme} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage
|
||||
theme={theme}
|
||||
|
||||
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -302,6 +302,9 @@ function pageTitle(page: AppPage | null) {
|
||||
case "meteo":
|
||||
return "Meteorologia";
|
||||
|
||||
case "synoptic":
|
||||
return "Sinótico";
|
||||
|
||||
case "settings":
|
||||
return "Configurações";
|
||||
|
||||
@@ -313,7 +316,6 @@ function pageTitle(page: AppPage | null) {
|
||||
case "climateCharts":
|
||||
case "climateLighting":
|
||||
case "climateVentilation":
|
||||
case "climateSynoptic":
|
||||
return "Clima";
|
||||
|
||||
// ALL IRRIGATION / REGA PAGES
|
||||
@@ -322,7 +324,6 @@ function pageTitle(page: AppPage | null) {
|
||||
case "irrigationFilters":
|
||||
case "irrigationConsumption":
|
||||
case "irrigationDrainage":
|
||||
case "irrigationSynoptic":
|
||||
return "Rega";
|
||||
|
||||
default:
|
||||
|
||||
@@ -37,19 +37,19 @@ const navigationItems: {
|
||||
}[] = [
|
||||
{ label: "Painel Principal", page: "dashboard", icon: Home },
|
||||
{ label: "Meteorologia", page: "meteo", icon: CloudSun },
|
||||
{ label: "Gráficos Gerais", page: "maincharts", icon: BarChart3 }
|
||||
{ label: "Gráficos Gerais", page: "maincharts", icon: BarChart3 },
|
||||
{ label: "Sinótico", page: "synoptic" , icon: MonitorDot }
|
||||
|
||||
];
|
||||
|
||||
const climateItems = [
|
||||
{ label: "Iluminação", icon: Lightbulb },
|
||||
{ label: "Ventilação", icon: Wind },
|
||||
{ label: "Sinótico", icon: MonitorDot },
|
||||
{ label: "Gráficos", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const irrigationItems = [
|
||||
{ label: "Regas", icon: Droplet },
|
||||
{ label: "Sinótico", icon: MonitorDot },
|
||||
{ label: "Filtros de Rega", icon: Filter },
|
||||
{ label: "Consumos", icon: Gauge },
|
||||
{ label: "Drenagem", icon: Waves },
|
||||
|
||||
@@ -262,8 +262,10 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) {
|
||||
>
|
||||
<header className={titleBarClass}>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-full flex-1 items-center px-3 text-xs font-black"
|
||||
onPointerDown={() => {
|
||||
void currentWindow.startDragging();
|
||||
}}
|
||||
>
|
||||
{chart.title}
|
||||
</div>
|
||||
|
||||
@@ -366,7 +366,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
|
||||
};
|
||||
|
||||
const closeDetachedChartWindow = async (chartId: string) => {
|
||||
const label = `chart-${chartId}`;
|
||||
const label = `climatecharts-${chartId}`;
|
||||
const existing = await WebviewWindow.getByLabel(label);
|
||||
|
||||
if (!existing) return;
|
||||
|
||||
@@ -365,7 +365,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
};
|
||||
|
||||
const closeDetachedChartWindow = async (chartId: string) => {
|
||||
const label = `chart-${chartId}`;
|
||||
const label = `maincharts-${chartId}`;
|
||||
const existing = await WebviewWindow.getByLabel(label);
|
||||
|
||||
if (!existing) return;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||
import type { HistorianPoint } from "../components/MeteoHistoryModal";
|
||||
|
||||
const BACKEND_URL = "http://localhost:18450";
|
||||
|
||||
type SensorHistoryMap = Record<string, HistorianPoint[]>;
|
||||
|
||||
export function useMeteoMultiHistory(
|
||||
sensors: Array<ModuleSensorResponse | null>,
|
||||
hours = 6,
|
||||
) {
|
||||
const [pointsByKey, setPointsByKey] = useState<SensorHistoryMap>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const sensorKeys = sensors
|
||||
.filter((sensor): sensor is ModuleSensorResponse => Boolean(sensor))
|
||||
.map((sensor) => sensor.key);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sensorKeys.length) {
|
||||
setPointsByKey({});
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - hours * 60 * 60 * 1000);
|
||||
|
||||
const entries = await Promise.all(
|
||||
sensorKeys.map(async (key) => {
|
||||
const params = new URLSearchParams({
|
||||
key,
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load history for ${key}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as HistorianPoint[];
|
||||
|
||||
return [key, payload] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
setPointsByKey(Object.fromEntries(entries));
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
console.error("Failed to load meteo histories", error);
|
||||
setPointsByKey({});
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [sensorKeys.join("|"), hours]);
|
||||
|
||||
return {
|
||||
pointsByKey,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useMemo } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Droplet,
|
||||
Gauge,
|
||||
Home,
|
||||
Layers3,
|
||||
Lightbulb,
|
||||
Thermometer,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ModuleSensorResponse } from "../../../types/meteo";
|
||||
import { useClimateModuleStream } from "../../climate/hooks/useClimateModuleStream";
|
||||
|
||||
export type SynopticVariableGroup = "Clima" | "Rega";
|
||||
|
||||
export type SynopticVariable = {
|
||||
id: string;
|
||||
sensorId: string;
|
||||
key: string;
|
||||
group: SynopticVariableGroup;
|
||||
label: string;
|
||||
value: string | number | boolean | null;
|
||||
unit: string;
|
||||
timestamp?: string;
|
||||
connected: boolean;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function useSynopticVariables() {
|
||||
const climate = useClimateModuleStream();
|
||||
|
||||
const variables = useMemo<SynopticVariable[]>(
|
||||
() =>
|
||||
toSynopticVariables({
|
||||
sensors: climate.sensors,
|
||||
group: "Clima",
|
||||
connected: climate.connected,
|
||||
fallbackTimestamp: climate.lastTimestamp,
|
||||
}),
|
||||
[
|
||||
climate.sensors,
|
||||
climate.connected,
|
||||
climate.lastTimestamp,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
variables,
|
||||
groups: groupVariables(variables),
|
||||
connected: climate.connected,
|
||||
climateConnected: climate.connected,
|
||||
loading: !climate.module,
|
||||
total: variables.length,
|
||||
};
|
||||
}
|
||||
|
||||
function toSynopticVariables({
|
||||
sensors,
|
||||
group,
|
||||
connected,
|
||||
fallbackTimestamp,
|
||||
}: {
|
||||
sensors: ModuleSensorResponse[];
|
||||
group: SynopticVariableGroup;
|
||||
connected: boolean;
|
||||
fallbackTimestamp: string | null;
|
||||
}): SynopticVariable[] {
|
||||
return sensors.map((sensor) => {
|
||||
const visual = getVariableVisual(sensor, group);
|
||||
|
||||
return {
|
||||
id: `${group}:${sensor.sensorId ?? sensor.key}`,
|
||||
sensorId: String(sensor.sensorId ?? sensor.key),
|
||||
key: sensor.key,
|
||||
group,
|
||||
label: sensor.name || sensor.key,
|
||||
value: normalizeValue(sensor.value),
|
||||
unit: sensor.unit ?? "",
|
||||
timestamp: sensor.timestamp ?? fallbackTimestamp ?? undefined,
|
||||
connected,
|
||||
icon: visual.icon,
|
||||
color: visual.color,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function groupVariables(variables: SynopticVariable[]) {
|
||||
return variables.reduce<Record<SynopticVariableGroup, SynopticVariable[]>>(
|
||||
(acc, variable) => {
|
||||
acc[variable.group].push(variable);
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
Clima: [],
|
||||
Rega: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeValue(value: unknown): string | number | boolean | null {
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
value === null
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getVariableVisual(
|
||||
sensor: ModuleSensorResponse,
|
||||
group: SynopticVariableGroup,
|
||||
): {
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
} {
|
||||
const text = normalizeText(`${sensor.key} ${sensor.name} ${sensor.unit ?? ""}`);
|
||||
|
||||
if (
|
||||
text.includes("temperatura") ||
|
||||
text.includes("temperature")
|
||||
) {
|
||||
return {
|
||||
icon: Thermometer,
|
||||
color: "#4FD1C5",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes("humidade") ||
|
||||
text.includes("humidity")
|
||||
) {
|
||||
return {
|
||||
icon: Droplet,
|
||||
color: "#38BDF8",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes("vento") ||
|
||||
text.includes("wind")
|
||||
) {
|
||||
return {
|
||||
icon: Wind,
|
||||
color: "#22D3EE",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes("radiacao") ||
|
||||
text.includes("radiação") ||
|
||||
text.includes("radiation")
|
||||
) {
|
||||
return {
|
||||
icon: Lightbulb,
|
||||
color: "#FACC15",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes("pressao") ||
|
||||
text.includes("pressão") ||
|
||||
text.includes("pressure")
|
||||
) {
|
||||
return {
|
||||
icon: Gauge,
|
||||
color: "#38BDF8",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes("nivel") ||
|
||||
text.includes("nível") ||
|
||||
text.includes("level")
|
||||
) {
|
||||
return {
|
||||
icon: Layers3,
|
||||
color: "#0EA5E9",
|
||||
};
|
||||
}
|
||||
|
||||
if (group === "Clima") {
|
||||
return {
|
||||
icon: Home,
|
||||
color: "#22C55E",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: Gauge,
|
||||
color: "#A3E635",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}/gu, "");
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export type WeatherForecastResponse = {
|
||||
location: WeatherLocation;
|
||||
current: WeatherCurrent;
|
||||
daily: WeatherDaily[];
|
||||
};
|
||||
|
||||
@@ -19,19 +18,6 @@ export type WeatherCondition = {
|
||||
code: number;
|
||||
};
|
||||
|
||||
export type WeatherCurrent = {
|
||||
temperatureC: number;
|
||||
feelsLikeC: number;
|
||||
humidity: number;
|
||||
precipitationMm: number;
|
||||
windKph: number;
|
||||
windDegree: number;
|
||||
windDirection: string;
|
||||
pressureMb: number;
|
||||
uv: number;
|
||||
condition: WeatherCondition;
|
||||
};
|
||||
|
||||
export type WeatherDaily = {
|
||||
date: string;
|
||||
maxTemperatureC: number;
|
||||
@@ -40,6 +26,11 @@ export type WeatherDaily = {
|
||||
totalPrecipitationMm: number;
|
||||
dailyRainChance: number;
|
||||
maxWindKph: number;
|
||||
averageWindKph: number;
|
||||
averageWindDegree: number;
|
||||
averageWindDirection: string;
|
||||
averageHumidity: number;
|
||||
averageVisibilityKm: number;
|
||||
uv: number;
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
|
||||