From 6e445227820078a1e78b70bfc32dd48552280279 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Mon, 1 Jun 2026 12:07:56 +0100 Subject: [PATCH] Implements meteoChartsPage, fixed responsiveness overall --- src-tauri/capabilities/default.json | 3 +- src/app/App.tsx | 18 +- src/components/cards/MetricCard.tsx | 108 - src/components/charts/WorkspaceChart.tsx | 99 +- src/components/layout/AppShell.tsx | 33 +- src/components/layout/BottomStatusBar.tsx | 47 - src/components/navigation/Sidebar.tsx | 112 +- .../chartworkspace/utils/openChartWindow.ts | 4 +- .../climate/hooks/useClimateChartSeries.ts | 2 +- .../components/DashboardMeteoSection.tsx | 0 .../components/DashboardOperationsSection.tsx | 60 - .../components/DashboardTrendChart.tsx | 180 -- .../dashboard/components/StatusPill.tsx | 31 - .../dashboard/pages/DashboardPage.tsx | 345 +--- .../maincharts/pages/MainChartsPage.tsx | 21 +- .../meteo/hooks/useAccumulatedHistory.ts | 83 +- .../meteo/hooks/useMeteoChartCatalog.ts | 35 + src/features/meteo/pages/MeteoChartsPage.tsx | 1813 +++++++++++++++++ src/features/meteo/pages/MeteoPage.tsx | 11 +- .../hooks/useTelemetryChartSeries.ts | 102 +- .../telemetry/hooks/useTelemetryStream.ts | 50 +- 21 files changed, 2276 insertions(+), 881 deletions(-) delete mode 100644 src/components/cards/MetricCard.tsx delete mode 100644 src/components/layout/BottomStatusBar.tsx delete mode 100644 src/features/dashboard/components/DashboardMeteoSection.tsx delete mode 100644 src/features/dashboard/components/DashboardOperationsSection.tsx delete mode 100644 src/features/dashboard/components/DashboardTrendChart.tsx delete mode 100644 src/features/dashboard/components/StatusPill.tsx create mode 100644 src/features/meteo/hooks/useMeteoChartCatalog.ts create mode 100644 src/features/meteo/pages/MeteoChartsPage.tsx diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6fc9493..401edc5 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,7 +6,8 @@ "main", "chart-*", "maincharts-*", - "climatecharts-*" + "climatecharts-*", + "meteocharts-*" ], "permissions": [ "core:default", diff --git a/src/app/App.tsx b/src/app/App.tsx index 007447b..3d78e6d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,6 +10,7 @@ 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"; +import MeteoChartsPage from "../features/meteo/pages/MeteoChartsPage"; export type AppPage = | "dashboard" @@ -44,7 +45,22 @@ function App() { return ( {({ theme }) => { - if (activePage === "meteo") return ; + if (activePage === "meteo") { + return ( + setActivePage("meteoCharts")} + /> + ); + } + + if (activePage === "meteoCharts") { + return ( + + ); + } if (activePage === "climateCharts") { return ; diff --git a/src/components/cards/MetricCard.tsx b/src/components/cards/MetricCard.tsx deleted file mode 100644 index d81d8d5..0000000 --- a/src/components/cards/MetricCard.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import type { LucideIcon } from "lucide-react"; - -type MetricCardProps = { - title: string; - value: string | number; - unit?: string; - icon: LucideIcon; - theme: "dark" | "light"; - accent?: "blue" | "green" | "yellow" | "cyan" | "red"; -}; - -const accentClasses = { - blue: { - icon: "text-sky-400", - bg: "bg-sky-500/10", - glow: "from-sky-500/20", - }, - green: { - icon: "text-emerald-400", - bg: "bg-emerald-500/10", - glow: "from-emerald-500/20", - }, - yellow: { - icon: "text-yellow-400", - bg: "bg-yellow-500/10", - glow: "from-yellow-500/20", - }, - cyan: { - icon: "text-cyan-400", - bg: "bg-cyan-500/10", - glow: "from-cyan-500/20", - }, - red: { - icon: "text-red-400", - bg: "bg-red-500/10", - glow: "from-red-500/20", - }, -}; - -export function MetricCard({ - title, - value, - unit, - icon: Icon, - theme, - accent = "blue", -}: MetricCardProps) { - const isDark = theme === "dark"; - const accentClass = accentClasses[accent]; - - return ( -
-
- -
-
- -
- -
-

- {title} -

- -
- - {value} - - - {unit && ( - - {unit} - - )} -
-
-
-
- ); -} \ No newline at end of file diff --git a/src/components/charts/WorkspaceChart.tsx b/src/components/charts/WorkspaceChart.tsx index 3055da3..bb972c0 100644 --- a/src/components/charts/WorkspaceChart.tsx +++ b/src/components/charts/WorkspaceChart.tsx @@ -81,6 +81,7 @@ type Props = { type ChartRow = { time: string; timestamp: string; + timestampMs: number; [key: string]: string | number | null | undefined; }; @@ -200,6 +201,7 @@ export function WorkspaceChart({ .map(([_, bucket]) => { const row: ChartRow = { timestamp: bucket.timestamp, + timestampMs: new Date(bucket.timestamp).getTime(), time: formatAxisTime(bucket.timestamp, chart.timeRange), }; @@ -216,6 +218,11 @@ export function WorkspaceChart({ }); }, [chart.variables, chart.timeRange, chart.interval]); + const displayData = useMemo( + () => downsampleRows(data, maxRowsForRange(chart.timeRange)), + [data, chart.timeRange], + ); + const stats = useMemo(() => { if (!primaryVariable) { return { @@ -279,16 +286,16 @@ export function WorkspaceChart({ className={ detached ? isDark - ? `${RADIUS} flex h-full flex-col overflow-hidden border-0 bg-[#0F1726] text-slate-100` - : `${RADIUS} flex h-full flex-col overflow-hidden border-0 bg-white text-slate-950` + ? `${RADIUS} flex h-full min-h-0 flex-col overflow-hidden border-0 bg-[#0F1726] text-slate-100` + : `${RADIUS} flex h-full min-h-0 flex-col overflow-hidden border-0 bg-white text-slate-950` : isDark - ? `${RADIUS} overflow-hidden border border-[#223049] bg-[#0F1726] text-slate-100 shadow-[0_18px_50px_rgba(0,0,0,0.24)]` - : `${RADIUS} overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_14px_34px_rgba(15,23,42,0.08)]` + ? `${RADIUS} flex h-full min-h-0 flex-col overflow-hidden border border-[#223049] bg-[#0F1726] text-slate-100 shadow-[0_18px_50px_rgba(0,0,0,0.24)]` + : `${RADIUS} flex h-full min-h-0 flex-col overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_14px_34px_rgba(15,23,42,0.08)]` } >
{dragHandle &&
{dragHandle}
} @@ -356,25 +363,15 @@ export function WorkspaceChart({
-
+
-
+
-
+
{visibleVariables.map((variable) => ( -
+
{visibleVariables.length === 0 && !shouldShowLoading ? ( ) : data.length === 0 && !shouldShowLoading ? ( @@ -625,11 +616,12 @@ export function WorkspaceChart({ ) : data.length > 0 ? ( {chart.mode === "bar" ? ( - + {visibleVariables.map((variable) => ( ))} ) : chart.mode === "area" ? ( - + {renderReferenceLines( visibleVariables, @@ -677,11 +671,12 @@ export function WorkspaceChart({ ))} ) : ( - + {renderReferenceLines( visibleVariables, @@ -720,7 +715,7 @@ export function WorkspaceChart({
{showIndicators && primaryVariable && ( -
+
@@ -780,15 +777,21 @@ function ChartScaffold({ /> + formatAxisTime(new Date(Number(value)).toISOString(), chartTimeRange) + } /> - {yAxes.map((axis) => ( index % step === 0); +} + export default WorkspaceChart; \ No newline at end of file diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 141c568..3c87a1b 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -34,6 +34,7 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) { }); const isDark = theme === "dark"; + const isDashboard = activePage === "dashboard"; useEffect(() => { localStorage.setItem(THEME_STORAGE_KEY, theme); @@ -47,14 +48,14 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
+ +
+
+
+

Bem-vindo ao
- - Litoral Central - + Litoral Central

-
+
-

+

A sua plataforma inteligente para gestão de operações agrícolas. - Acompanhe as condições, controle os sistemas e maximize a - eficiência no campo. + Acompanhe as condições, controle os sistemas e maximize a eficiência no campo.

- +
+ +
-
- } - iconClass={ - isDark - ? "bg-[#13202F] text-[#4FD1C5]" - : "bg-[#ECFDF5] text-[#0F766E]" - } - title="Meteorologia" - text="Consulte previsões, vento, radiação solar e condições meteorológicas." - onClick={() => onNavigate("meteo")} - /> - - } - iconClass={ - isDark - ? "bg-[#13202F] text-[#7DD3FC]" - : "bg-[#EFF6FF] text-[#0369A1]" - } - title="Consola VNC" - text="Aceda remotamente ao controlador e acompanhe a instalação em tempo real." - onClick={() => onNavigate("console")} - /> - - } - iconClass={ - isDark - ? "bg-[#171B2B] text-[#A5B4FC]" - : "bg-[#EEF2FF] text-[#4F46E5]" - } - title="Gráficos Climáticos" - text="Visualize históricos, tendências e análise detalhada dos sensores." - onClick={() => onNavigate("climateCharts")} - /> +
+ } iconClass={isDark ? "bg-[#13202F] text-[#4FD1C5]" : "bg-[#ECFDF5] text-[#0F766E]"} title="Meteorologia" text="Consulte previsões, vento, radiação solar e condições meteorológicas." onClick={() => onNavigate("meteo")} /> + } iconClass={isDark ? "bg-[#13202F] text-[#7DD3FC]" : "bg-[#EFF6FF] text-[#0369A1]"} title="Consola VNC" text="Aceda remotamente ao controlador e acompanhe a instalação em tempo real." onClick={() => onNavigate("console")} /> + } iconClass={isDark ? "bg-[#171B2B] text-[#A5B4FC]" : "bg-[#EEF2FF] text-[#4F46E5]"} title="Gráficos Climáticos" text="Visualize históricos, tendências e análise detalhada dos sensores." onClick={() => onNavigate("climateCharts")} />
-
-
-
-

- A Nossa Missão -

+
+
+
+

A Nossa Missão

-

+

Soluções inovadoras para
- uma{" "} - - agricultura sustentável - + uma agricultura sustentável

-

- Tecnologia, conhecimento e proximidade para impulsionar o futuro - do setor agrícola em Portugal. +

+ Tecnologia, conhecimento e proximidade para impulsionar o futuro do setor agrícola em Portugal.

-
- } - title="Sustentabilidade" - text="Compromisso com o futuro" - /> - } - title="Confiabilidade" - text="Tecnologia robusta e segura" - /> - } - title="Apoio próximo" - text="Sempre ao seu lado" - /> +
+ } title="Sustentabilidade" text="Compromisso com o futuro" /> + } title="Confiabilidade" text="Tecnologia robusta e segura" /> + } title="Apoio próximo" text="Sempre ao seu lado" />
@@ -245,126 +123,55 @@ export function DashboardPage({
-
+
© 2026 Litoral Central. Todos os direitos reservados. - - Feito em Portugal 🇵🇹 - + Feito em Portugal 🇵🇹
); } -function InfoCard({ - theme, - icon, - iconClass, - title, - text, - onClick, -}: { - theme: "dark" | "light"; - icon: React.ReactNode; - iconClass: string; - title: string; - text: string; - onClick?: () => void; -}) { +function InfoCard({ theme, icon, iconClass, title, text, onClick }: { theme: "dark" | "light"; icon: ReactNode; iconClass: string; title: string; text: string; onClick?: () => void }) { const isDark = theme === "dark"; return ( - ); } -function MissionItem({ - theme, - icon, - title, - text, -}: { - theme: "dark" | "light"; - icon: React.ReactNode; - title: string; - text: string; -}) { +function MissionItem({ theme, icon, title, text }: { theme: "dark" | "light"; icon: ReactNode; title: string; text: string }) { const isDark = theme === "dark"; return ( -
-
+
+
{icon}
-
-

+ +
+

{title}

-

+

{text}

@@ -373,18 +180,12 @@ function MissionItem({ } function FarmIllustration({ theme }: { theme: "dark" | "light" }) { - const isDark = theme === "dark"; - return ( -
+
); diff --git a/src/features/maincharts/pages/MainChartsPage.tsx b/src/features/maincharts/pages/MainChartsPage.tsx index 6dc99bf..5eb429e 100644 --- a/src/features/maincharts/pages/MainChartsPage.tsx +++ b/src/features/maincharts/pages/MainChartsPage.tsx @@ -536,7 +536,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { }; }, [layoutMode]); return ( -
+
@@ -1461,17 +1461,20 @@ function EmptyVariableList({ theme }: { theme: "dark" | "light" }) { } function layoutGridClass(layoutMode: ChartLayoutMode) { - if (layoutMode === "twoColumns") { - return "grid gap-4 2xl:grid-cols-2"; - } - if (layoutMode === "fourGrid") { - return "grid gap-4 2xl:grid-cols-2"; + return "grid min-h-0 flex-1 grid-cols-2 grid-rows-2 gap-3 overflow-hidden"; } - return "grid gap-4"; -} + if (layoutMode === "twoColumns") { + return "grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden"; + } + if (layoutMode === "twoRows") { + return "grid min-h-0 flex-1 grid-rows-2 gap-3 overflow-hidden"; + } + + return "grid min-h-0 flex-1 gap-3 overflow-hidden"; +} function getVisibleSlotCount(layoutMode: ChartLayoutMode) { if (layoutMode === "single") return 1; if (layoutMode === "twoColumns") return 2; diff --git a/src/features/meteo/hooks/useAccumulatedHistory.ts b/src/features/meteo/hooks/useAccumulatedHistory.ts index f4d5798..065c8f3 100644 --- a/src/features/meteo/hooks/useAccumulatedHistory.ts +++ b/src/features/meteo/hooks/useAccumulatedHistory.ts @@ -21,53 +21,68 @@ export function useAccumulatedHistory( const [loading, setLoading] = useState(false); useEffect(() => { - if (!sensor) { + if (!sensor || !sensor.key) { + console.warn("[AccumulatedHistory SKIPPED] sensor is null or missing key", { + sensor, + range, + }); + setBuckets([]); + setLoading(false); return; } - const sensorKey = sensor.key; const controller = new AbortController(); - async function loadAccumulated() { - try { - setLoading(true); + const sensorKey = sensor.key + async function loadAccumulated() { + setLoading(true); + + try { const params = new URLSearchParams({ key: sensorKey, range, }); + const url = `${BACKEND_URL}/api/historian/accumulated?${params.toString()}`; - console.log("I AM HEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEERE"); - console.log("[AccumulatedHistory URL]", url); - const response = await fetch( - `${BACKEND_URL}/api/historian/accumulated?${params.toString()}`, - { signal: controller.signal }, - ); - - if (!response.ok) { - throw new Error("Failed to load accumulated history"); - } - - const payload = ((await response.json()) as AccumulatedBucket[]).sort( - (a, b) => new Date(a.from).getTime() - new Date(b.from).getTime(), - ); - - const todayBucket = payload[payload.length - 1] ?? null; - - console.log("[AccumulatedHistory]", { - sensorKey, - range, - buckets: payload, - today: todayBucket, + const response = await fetch(url, { + method: "GET", + signal: controller.signal, + cache: "no-store", + headers: { + Accept: "application/json", + "Cache-Control": "no-cache", + Pragma: "no-cache", + }, }); - setBuckets(payload); + const text = await response.text(); + + if (!response.ok) { + throw new Error( + `Failed to load accumulated history: ${response.status} ${text}`, + ); + } + + const parsed = JSON.parse(text) as AccumulatedBucket[]; + + if (!Array.isArray(parsed)) { + throw new Error("Accumulated history response is not an array"); + } + + const sortedPayload = [...parsed].sort( + (a, b) => + new Date(a.from).getTime() - + new Date(b.from).getTime(), + ); + + setBuckets(sortedPayload); } catch (error) { if (controller.signal.aborted) return; - console.error("Failed to load accumulated history", error); + console.error("[AccumulatedHistory ERROR]", error); setBuckets([]); } finally { if (!controller.signal.aborted) { @@ -78,13 +93,13 @@ export function useAccumulatedHistory( loadAccumulated(); - return () => controller.abort(); - }, [sensor?.key, range]); + return () => { + controller.abort(); + }; + }, [sensor?.key, sensor?.name, range]); return { buckets, loading, }; -} - -export type { AccumulatedRange }; \ No newline at end of file +} \ No newline at end of file diff --git a/src/features/meteo/hooks/useMeteoChartCatalog.ts b/src/features/meteo/hooks/useMeteoChartCatalog.ts new file mode 100644 index 0000000..cb5770c --- /dev/null +++ b/src/features/meteo/hooks/useMeteoChartCatalog.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; +import type { ChartVariable } from "../../telemetry/types/telemetryCatalog"; +import { useMeteoModuleStream } from "./useMeteoModuleStream"; + +export function useMeteoChartCatalog() { + const { sensors, connected } = useMeteoModuleStream(); + + const chartableVariables = useMemo( + () => + sensors.map((sensor) => ({ + sensorId: sensor.sensorId, + key: sensor.key, + label: sensor.name, + value: + typeof sensor.value === "number" || + typeof sensor.value === "string" || + typeof sensor.value === "boolean" || + sensor.value === null + ? sensor.value + : null, + unit: sensor.unit ?? "", + timestamp: sensor.timestamp, + category: "Meteo", + group: "Meteorologia", + chartable: true, + })), + [sensors], + ); + + return { + chartableVariables, + connected, + sensorCount: sensors.length, + }; +} \ No newline at end of file diff --git a/src/features/meteo/pages/MeteoChartsPage.tsx b/src/features/meteo/pages/MeteoChartsPage.tsx new file mode 100644 index 0000000..a9c4561 --- /dev/null +++ b/src/features/meteo/pages/MeteoChartsPage.tsx @@ -0,0 +1,1813 @@ +import { useEffect, useMemo, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { + AreaChart, + BarChart3, + ChevronDown, + Cog, + Columns2, + Copy, + Grid2X2, + GripVertical, + LineChart, + Maximize2, + PanelTop, + Play, + Plus, + Rows2, + Search, + Trash2, + X, +} from "lucide-react"; + +import { ChartConfigModal } from "../../chartworkspace/components/ChartConfigModal"; +import { openChartWindow } from "../../chartworkspace/utils/openChartWindow"; + +import { + WorkspaceChart, + type WorkspaceChartConfig, + type WorkspaceChartInterval, + type WorkspaceChartMode, + type WorkspaceChartTimeRange, +} from "../../../components/charts/WorkspaceChart"; + +import type { ChartVariable } from "../../telemetry/types/telemetryCatalog"; +import { useMeteoChartCatalog } from "../hooks/useMeteoChartCatalog"; + +import { + useChartWorkspacePersistence, + type ChartLayoutMode, + type PersistedChartWorkspaceItem, +} from "../../chartworkspace/hooks/useChartWorkspacePersistence"; + +type MeteoChartsPageProps = { + theme: "dark" | "light"; +}; + +type HistorianPoint = { + timestamp: string; + numericValue: number | null; + value?: number | string; + v?: number | string; + total?: number | string; +}; + +type ChartWorkspaceItem = PersistedChartWorkspaceItem & { + hidden?: boolean; + collapsed?: boolean; + id: string; + title: string; + subtitle: string; + mode: WorkspaceChartMode; + selectedSensorKeys: string[]; + hiddenSensorKeys?: string[]; + timeRange: WorkspaceChartTimeRange; + interval: WorkspaceChartInterval; + detached?: boolean; + windowX?: number; + windowY?: number; + windowWidth?: number; + windowHeight?: number; + windowZIndex?: number; +}; + +const BACKEND_URL = "http://localhost:18450"; +const RADIUS = "rounded-[6px]"; +const MAX_CHARTS = 10; +const MAX_VARIABLES_PER_CHART = 6; + +const INITIAL_CHARTS: ChartWorkspaceItem[] = []; + +export function MeteoChartsPage({ + theme, +}: MeteoChartsPageProps) { + + const isDark = theme === "dark"; + + const { + chartableVariables, + connected, + } = useMeteoChartCatalog(); + + const [layoutMode, setLayoutMode] = useState("twoColumns"); + const [charts, setCharts] = useState(INITIAL_CHARTS); + const [configChartId, setConfigChartId] = useState(null); + const [savedSearch, setSavedSearch] = useState(""); + const [savedOpen, setSavedOpen] = useState(false); + const [movingChartId, setMovingChartId] = useState(null); + const [newChartOpen, setNewChartOpen] = useState(false); + const [placingChartId, setPlacingChartId] = useState(null); + + const changeLayoutMode = (nextLayoutMode: ChartLayoutMode) => { + const nextVisibleCount = getVisibleSlotCount(nextLayoutMode); + + setCharts((current) => { + let openSeen = 0; + + return current.map((chart) => { + if (chart.hidden) return chart; + + openSeen += 1; + + return { + ...chart, + collapsed: openSeen > nextVisibleCount, + }; + }); + }); + + setLayoutMode(nextLayoutMode); + setPlacingChartId(null); + setMovingChartId(null); + }; + + const workspacePersistence = useChartWorkspacePersistence({ + scope: "METEO", + layoutMode, + charts, + onLoaded: (workspace) => { + setLayoutMode(workspace.layoutMode); + setCharts(workspace.charts as ChartWorkspaceItem[]); + }, + }); + + const canAddMoreCharts = charts.length < MAX_CHARTS; + + const visibleCharts = useMemo(() => { + const openCharts = charts.filter( + (chart) => !chart.hidden && !chart.collapsed && !chart.detached, + ); + + if (layoutMode === "single") return openCharts.slice(0, 1); + if (layoutMode === "twoColumns") return openCharts.slice(0, 2); + if (layoutMode === "twoRows") return openCharts.slice(0, 2); + return openCharts.slice(0, 4); + }, [charts, layoutMode]); + + const savedCharts = useMemo( + () => + charts.filter((chart) => + chart.title.toLowerCase().includes(savedSearch.toLowerCase()), + ), + [charts, savedSearch], + ); + + const configChart = charts.find((chart) => chart.id === configChartId) ?? null; + + const swapCharts = (sourceId: string, targetId: string) => { + if (sourceId === targetId) { + setMovingChartId(null); + return; + } + + setCharts((current) => { + const sourceIndex = current.findIndex((chart) => chart.id === sourceId); + const targetIndex = current.findIndex((chart) => chart.id === targetId); + + if (sourceIndex === -1 || targetIndex === -1) return current; + + const next = [...current]; + [next[sourceIndex], next[targetIndex]] = [ + next[targetIndex], + next[sourceIndex], + ]; + + return next; + }); + + setMovingChartId(null); + }; + + const addChart = ({ + title, + mode, + selectedSensorKeys, + }: { + title: string; + mode: WorkspaceChartMode; + selectedSensorKeys: string[]; + }) => { + if (!canAddMoreCharts) return; + + const newChartId = `chart-${Date.now()}`; + + setCharts((current) => { + const visibleSlotCount = getVisibleSlotCount(layoutMode); + const visibleOpenCount = current.filter( + (chart) => !chart.hidden && !chart.collapsed && !chart.detached, + ).length; + + const shouldAskPlacement = visibleOpenCount >= visibleSlotCount; + + const newChart: ChartWorkspaceItem = { + id: newChartId, + title, + subtitle: "Gráfico personalizado de meteorologia.", + mode, + selectedSensorKeys, + timeRange: "24h", + interval: "5m", + hidden: false, + collapsed: shouldAskPlacement, + }; + + if (shouldAskPlacement) setPlacingChartId(newChartId); + + return [...current, newChart]; + }); + + setNewChartOpen(false); + }; + + const duplicateChart = (chartId: string) => { + if (!canAddMoreCharts) return; + + const chart = charts.find((item) => item.id === chartId); + if (!chart) return; + + const copy: ChartWorkspaceItem = { + ...chart, + id: `chart-${Date.now()}`, + title: `${chart.title} cópia`, + }; + + setCharts((current) => { + const next = [...current, copy]; + + if (next.length >= 3) { + setLayoutMode("fourGrid"); + } else if (next.length === 2) { + setLayoutMode("twoColumns"); + } + + return next; + }); + }; + + const setChartMode = (chartId: string, mode: WorkspaceChartMode) => { + setCharts((current) => + current.map((chart) => + chart.id === chartId ? { ...chart, mode } : chart, + ), + ); + }; + + const toggleVariable = (chartId: string, key: string) => { + setCharts((current) => + current.map((chart) => { + if (chart.id !== chartId) return chart; + + const hiddenSensorKeys = chart.hiddenSensorKeys ?? []; + const isHidden = hiddenSensorKeys.includes(key); + + return { + ...chart, + hiddenSensorKeys: isHidden + ? hiddenSensorKeys.filter((item) => item !== key) + : [...hiddenSensorKeys, key], + }; + }), + ); + }; + + const loadSavedChart = (chartId: string) => { + setCharts((current) => { + const selected = current.find((chart) => chart.id === chartId); + if (!selected) return current; + + const remaining = current.filter((chart) => chart.id !== chartId); + + return [ + { ...selected, hidden: false, collapsed: false }, + ...remaining, + ]; + }); + + setSavedOpen(false); + setPlacingChartId(null); + }; + + const closeDetachedChartWindow = async (chartId: string) => { + const label = `meteocharts-${chartId}`; + const existing = await WebviewWindow.getByLabel(label); + + if (!existing) return; + + try { + await existing.destroy(); + } catch (error) { + console.error(`Failed to destroy detached chart window: ${label}`, error); + } + }; + + const closeChart = async (chartId: string) => { + await closeDetachedChartWindow(chartId); + + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + detached: false, + hidden: true, + collapsed: false, + } + : chart, + ), + ); + + if (configChartId === chartId) setConfigChartId(null); + if (movingChartId === chartId) setMovingChartId(null); + if (placingChartId === chartId) setPlacingChartId(null); + }; + + const deleteChart = async (chartId: string) => { + await closeDetachedChartWindow(chartId); + + setCharts((current) => current.filter((chart) => chart.id !== chartId)); + + if (configChartId === chartId) setConfigChartId(null); + if (movingChartId === chartId) setMovingChartId(null); + if (placingChartId === chartId) setPlacingChartId(null); + }; + + const placeChartHere = (targetChartId: string) => { + if (!placingChartId || placingChartId === targetChartId) return; + + setCharts((current) => { + const sourceIndex = current.findIndex((chart) => chart.id === placingChartId); + const targetIndex = current.findIndex((chart) => chart.id === targetChartId); + + if (sourceIndex === -1 || targetIndex === -1) return current; + + const next = [...current]; + const source = next[sourceIndex]; + const target = next[targetIndex]; + + next[targetIndex] = { + ...source, + hidden: false, + collapsed: false, + }; + + next[sourceIndex] = { + ...target, + hidden: false, + collapsed: true, + }; + + return next; + }); + + setPlacingChartId(null); + }; + + const openSavedChart = (chartId: string) => { + const chart = charts.find((item) => item.id === chartId); + if (!chart) return; + + const isActuallyOpen = !chart.hidden && !chart.collapsed && !chart.detached; + if (isActuallyOpen) return; + + const visibleSlotCount = getVisibleSlotCount(layoutMode); + const visibleOpenCount = charts.filter( + (item) => !item.hidden && !item.collapsed && !item.detached, + ).length; + + if (visibleOpenCount === 0 || visibleOpenCount < visibleSlotCount) { + loadSavedChart(chartId); + return; + } + + setPlacingChartId(chartId); + setSavedOpen(false); + }; + + const detachChart = (chartId: string) => { + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + detached: true, + hidden: false, + collapsed: false, + } + : chart, + ), + ); + + window.setTimeout(() => { + const chart = charts.find((item) => item.id === chartId); + + openChartWindow( + chartId, + theme, + chart?.title ?? "Meteo Chart", + "METEO", + "meteocharts", + ); + }, 100); + }; + + const attachChart = async (chartId: string) => { + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + detached: false, + hidden: false, + collapsed: false, + } + : chart, + ), + ); + + await closeDetachedChartWindow(chartId); + }; + + const moveDetachedChart = (chartId: string, x: number, y: number) => { + setCharts((current) => + current.map((chart) => + chart.id === chartId ? { ...chart, windowX: x, windowY: y } : chart, + ), + ); + }; + + useEffect(() => { + const unlistenAttachPromise = listen<{ + chartId: string; + chart: ChartWorkspaceItem | null; + }>("meteocharts://attach-chart", (event) => { + const chartId = event.payload.chartId; + const updatedChart = event.payload.chart; + + setCharts((current) => { + const visibleSlotCount = getVisibleSlotCount(layoutMode); + + const visibleOpenCount = current.filter( + (chart) => + !chart.hidden && + !chart.collapsed && + !chart.detached && + chart.id !== chartId, + ).length; + + const shouldAskPlacement = visibleOpenCount >= visibleSlotCount; + + if (shouldAskPlacement) setPlacingChartId(chartId); + + return current.map((chart) => + chart.id === chartId + ? { + ...chart, + ...(updatedChart ?? {}), + detached: false, + hidden: false, + collapsed: shouldAskPlacement, + } + : chart, + ); + }); + }); + + const unlistenHidePromise = listen<{ + chartId?: string; + chart?: ChartWorkspaceItem | null; + }>("meteocharts://hide-chart", (event) => { + const chartId = event.payload.chartId ?? event.payload.chart?.id; + if (!chartId) return; + + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + ...(event.payload.chart ?? {}), + detached: false, + hidden: true, + collapsed: false, + } + : chart, + ), + ); + + setPlacingChartId((current) => (current === chartId ? null : current)); + }); + + const unlistenUpdatePromise = listen<{ + chartId: string; + patch: Partial; + }>("meteocharts://update-chart", (event) => { + const { chartId, patch } = event.payload; + + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + ...patch, + } + : chart, + ), + ); + }); + + const unlistenReplacePromise = listen<{ chart: ChartWorkspaceItem }>( + "meteocharts://replace-chart", + (event) => { + const updatedChart = event.payload.chart; + + setCharts((current) => + current.map((chart) => + chart.id === updatedChart.id + ? { ...chart, ...updatedChart } + : chart, + ), + ); + }, + ); + + return () => { + void unlistenAttachPromise.then((unlisten) => unlisten()); + void unlistenHidePromise.then((unlisten) => unlisten()); + void unlistenUpdatePromise.then((unlisten) => unlisten()); + void unlistenReplacePromise.then((unlisten) => unlisten()); + }; + }, [layoutMode]); + + return ( +
+
+
+ } + title="1 gráfico" + onClick={() => changeLayoutMode("single")} + /> + + } + title="2 lado a lado" + onClick={() => changeLayoutMode("twoColumns")} + /> + + } + title="2 vertical" + onClick={() => changeLayoutMode("twoRows")} + /> + + } + title="4 gráficos" + onClick={() => changeLayoutMode("fourGrid")} + /> +
+ +
+
+ {!workspacePersistence.loaded + ? "A carregar..." + : workspacePersistence.saving + ? "A guardar..." + : workspacePersistence.error + ? workspacePersistence.error + : "Workspace guardado"} +
+ +
+ + + {savedOpen && ( + { + setSavedOpen(false); + setPlacingChartId(null); + }} + onStartPlacement={openSavedChart} + onDelete={deleteChart} + /> + )} +
+ + +
+
+ + {visibleCharts.length === 0 ? ( + { + if (canAddMoreCharts) setNewChartOpen(true); + }} + onOpenSaved={() => setSavedOpen(true)} + /> + ) : ( +
+ {visibleCharts.map((chartItem) => ( + + ))} + + {placingChartId && visibleCharts.length < getVisibleSlotCount(layoutMode) && ( + + )} +
+ )} + + {newChartOpen && ( + setNewChartOpen(false)} + onCreate={addChart} + /> + )} + + {configChart && ( + setConfigChartId(null)} + onSave={(updatedChart) => { + setCharts((current) => + current.map((chart) => + chart.id === updatedChart.id + ? { ...chart, ...updatedChart } + : chart, + ), + ); + + setConfigChartId(null); + }} + /> + )} +
+ ); +} + +function WorkspaceChartContainer({ + theme, + chartItem, + chartableVariables, + connected, + movingChartId, + setMovingChartId, + swapCharts, + duplicateChart, + closeChart, + setConfigChartId, + setChartMode, + toggleVariable, + setCharts, + placingChartId, + placeChartHere, + detachChart, + attachChart, + moveDetachedChart, +}: { + theme: "dark" | "light"; + chartItem: ChartWorkspaceItem; + chartableVariables: ChartVariable[]; + connected: boolean; + movingChartId: string | null; + setMovingChartId: React.Dispatch>; + swapCharts: (sourceId: string, targetId: string) => void; + duplicateChart: (chartId: string) => void; + closeChart: (chartId: string) => void | Promise; + setConfigChartId: React.Dispatch>; + setChartMode: (chartId: string, mode: WorkspaceChartMode) => void; + toggleVariable: (chartId: string, key: string) => void; + setCharts: React.Dispatch>; + placingChartId: string | null; + placeChartHere: (targetChartId: string) => void; + detachChart: (chartId: string) => void; + attachChart: (chartId: string) => void | Promise; + moveDetachedChart: (chartId: string, x: number, y: number) => void; +}) { + const isMoving = movingChartId === chartItem.id; + + const canReceiveMove = + (movingChartId !== null && movingChartId !== chartItem.id) || + (placingChartId !== null && placingChartId !== chartItem.id); + + const { seriesByKey, loading } = useMeteoChartSeries( + chartItem.selectedSensorKeys, + chartItem.timeRange, + ); + + const selectedVariables = useMemo( + () => + chartableVariables.filter((variable) => + chartItem.selectedSensorKeys.includes(variable.key), + ), + [ + chartItem.selectedSensorKeys.join("|"), + chartableVariables + .map((variable) => `${variable.key}:${variable.label}:${variable.unit}`) + .join("|"), + ], + ); + + const variablesStillResolving = + chartItem.selectedSensorKeys.length > 0 && selectedVariables.length === 0; + + const chartConfig: WorkspaceChartConfig = { + id: chartItem.id, + title: chartItem.title, + subtitle: + chartItem.selectedSensorKeys.length > 0 + ? `${chartItem.selectedSensorKeys.length} sensores selecionados` + : chartItem.subtitle, + icon: BarChart3, + status: connected ? "online" : "offline", + sourceLabel: connected ? "Meteo Historian" : "Offline", + mode: chartItem.mode, + timeRange: chartItem.timeRange, + interval: chartItem.interval, + variables: selectedVariables.map((variable, index) => ({ + key: variable.key, + label: variable.label, + unit: variable.unit, + color: getVariableColor(index), + data: seriesByKey[variable.key] ?? [], + visible: !(chartItem.hiddenSensorKeys ?? []).includes(variable.key), + })), + }; + + return ( +
{ + if (movingChartId && movingChartId !== chartItem.id) { + swapCharts(movingChartId, chartItem.id); + } + }} + className={ + canReceiveMove + ? "relative rounded-[7px] ring-2 ring-[#4FD1C5]/50 transition" + : isMoving + ? "relative rounded-[7px] opacity-60 ring-2 ring-[#4FD1C5]" + : "relative rounded-[7px]" + } + > +
+ + + + + + + +
+ + {canReceiveMove && ( +
+ +
+ )} + + detachChart(chartItem.id)} + onAttach={() => attachChart(chartItem.id)} + onTimeRangeChange={(range) => + setCharts((current) => + current.map((chart) => + chart.id === chartItem.id + ? { ...chart, timeRange: range } + : chart, + ), + ) + } + onIntervalChange={(interval) => + setCharts((current) => + current.map((chart) => + chart.id === chartItem.id + ? { ...chart, interval } + : chart, + ), + ) + } + dragHandle={ + + } + onModeChange={(mode) => setChartMode(chartItem.id, mode)} + onVariableToggle={(variableKey) => + toggleVariable(chartItem.id, variableKey) + } + onHeaderPointerDown={ + chartItem.detached + ? (event) => { + const startX = event.clientX; + const startY = event.clientY; + const initialX = chartItem.windowX ?? 120; + const initialY = chartItem.windowY ?? 120; + + const handlePointerMove = (moveEvent: PointerEvent) => { + moveDetachedChart( + chartItem.id, + initialX + moveEvent.clientX - startX, + initialY + moveEvent.clientY - startY, + ); + }; + + const handlePointerUp = () => { + window.removeEventListener( + "pointermove", + handlePointerMove, + ); + window.removeEventListener( + "pointerup", + handlePointerUp, + ); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + } + : undefined + } + /> +
+ ); +} + +function useMeteoChartSeries( + selectedSensorKeys: string[], + timeRange: WorkspaceChartTimeRange, +) { + const [seriesByKey, setSeriesByKey] = useState>({}); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (selectedSensorKeys.length === 0) { + setSeriesByKey({}); + setLoading(false); + return; + } + + const controller = new AbortController(); + + async function loadSeries() { + try { + const to = new Date(); + const from = new Date( + to.getTime() - timeRangeToMilliseconds(timeRange), + ); + + setLoading(true); + + const entries = await Promise.all( + selectedSensorKeys.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, + cache: "no-store", + headers: { + Accept: "application/json", + "Cache-Control": "no-cache", + Pragma: "no-cache", + }, + }, + ); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to load meteo history for ${key}: ${response.status} ${text}`, + ); + } + + const payload = (await response.json()) as HistorianPoint[]; + + return [ + key, + payload.map((point) => ({ + timestamp: getHistorianPointTimestamp(point), + value: getHistorianPointValue(point), + })), + ] as const; + }), + ); + + setSeriesByKey(Object.fromEntries(entries)); + } catch (error) { + if (controller.signal.aborted) return; + + console.error("[MeteoCharts ERROR]", error); + setSeriesByKey({}); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + void loadSeries(); + + return () => { + controller.abort(); + }; + }, [selectedSensorKeys.join("|"), timeRange]); + + return { seriesByKey, loading }; +} + +type ChartPoint = { + timestamp: string; + value: number; +}; + +function getHistorianPointTimestamp(point: HistorianPoint): string { + return point.timestamp; +} + +function getHistorianPointValue(point: HistorianPoint): number { + if (typeof point.numericValue === "number") { + return point.numericValue; + } + + const rawValue = point.value ?? point.v ?? point.total; + + if (typeof rawValue === "number") return rawValue; + + if (typeof rawValue === "string" && rawValue.trim() !== "") { + const parsed = Number(rawValue); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +} + +function timeRangeToMilliseconds(timeRange: WorkspaceChartTimeRange): number { + if (timeRange === "15m") return 15 * 60 * 1000; + if (timeRange === "24h") return 24 * 60 * 60 * 1000; + if (timeRange === "7d") return 7 * 24 * 60 * 60 * 1000; + if (timeRange === "30d") return 30 * 24 * 60 * 60 * 1000; + + return 24 * 60 * 60 * 1000; +} + +function floatingIconClass(theme: "dark" | "light") { + return theme === "dark" + ? `${RADIUS} grid h-8 w-8 place-items-center border border-[#263247] bg-[#111A2B] text-[#A8B3C7] transition hover:text-white` + : `${RADIUS} grid h-8 w-8 place-items-center border border-[#D7DEE8] bg-white text-slate-500 transition hover:text-[#0F172A]`; +} + +function SavedChartsDropdown({ + theme, + charts, + search, + onSearchChange, + onClose, + onStartPlacement, + onDelete, +}: { + theme: "dark" | "light"; + charts: ChartWorkspaceItem[]; + search: string; + onSearchChange: (value: string) => void; + onClose: () => void; + onStartPlacement: (chartId: string) => void; + onDelete: (chartId: string) => void | Promise; +}) { + const isDark = theme === "dark"; + + return ( +
+
+
+

Gráficos Guardados

+

+ {charts.length}/10 gráficos no workspace. +

+
+ + +
+ +
+ + onSearchChange(event.target.value)} + placeholder="Pesquisar gráficos..." + className="w-full bg-transparent outline-none placeholder:text-inherit" + /> +
+ +
+ {charts.map((chart) => ( +
+
+

{chart.title}

+

+ {chart.selectedSensorKeys.length} sensores ·{" "} + {chart.timeRange} · {chart.interval} +

+
+ +
+ {chart.detached && !chart.hidden ? ( + + Destacado + + ) : chart.hidden || chart.collapsed ? ( + + ) : ( + + Aberto + + )} + + +
+
+ ))} + + {charts.length === 0 && ( +
+ Nenhum gráfico guardado. +
+ )} +
+
+ ); +} + +function NewChartModal({ + theme, + variables, + chartNumber, + onClose, + onCreate, +}: { + theme: "dark" | "light"; + variables: ChartVariable[]; + chartNumber: number; + onClose: () => void; + onCreate: (chart: { + title: string; + mode: WorkspaceChartMode; + selectedSensorKeys: string[]; + }) => void; +}) { + const isDark = theme === "dark"; + const [title, setTitle] = useState(`Novo Gráfico ${chartNumber}`); + const [mode, setMode] = useState("line"); + const [search, setSearch] = useState(""); + const [selectedSensorKeys, setSelectedSensorKeys] = useState([]); + + const filteredVariables = variables.filter((variable) => + `${variable.label} ${variable.category} ${variable.group} ${variable.unit}` + .toLowerCase() + .includes(search.toLowerCase()), + ); + + const toggleVariable = (key: string) => { + setSelectedSensorKeys((current) => { + const alreadySelected = current.includes(key); + + if (alreadySelected) { + return current.filter((item) => item !== key); + } + + if (current.length >= MAX_VARIABLES_PER_CHART) { + return current; + } + + return [...current, key]; + }); + }; + + const canCreate = title.trim().length > 0 && selectedSensorKeys.length > 0; + + return ( +
+
+
+
+

+ Novo gráfico +

+ +

+ Criar gráfico meteorológico +

+
+ + +
+ +
+
+
+ + setTitle(event.target.value)} + className={ + isDark + ? `${RADIUS} h-11 w-full border border-[#263247] bg-[#07101B] px-3 text-sm font-bold text-white outline-none` + : `${RADIUS} h-11 w-full border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm font-bold text-[#0F172A] outline-none` + } + /> +
+ +
+ + +
+ } + label="Linha" + onClick={() => setMode("line")} + /> + } + label="Área" + onClick={() => setMode("area")} + /> + } + label="Barras" + onClick={() => setMode("bar")} + /> +
+
+ +
+

+ Selecionadas +

+ +

+ {selectedSensorKeys.length}/{MAX_VARIABLES_PER_CHART} +

+
+
+ +
+ + +
+ + setSearch(event.target.value)} + placeholder="Pesquisar sensor..." + className="w-full bg-transparent outline-none placeholder:text-inherit" + /> +
+ +
+ {filteredVariables.map((variable, index) => { + const active = selectedSensorKeys.includes(variable.key); + + return ( + = + MAX_VARIABLES_PER_CHART + } + onClick={() => toggleVariable(variable.key)} + /> + ); + })} + + {filteredVariables.length === 0 && ( + + )} +
+
+
+ +
+ + + +
+
+
+ ); +} + +function VariableRow({ + theme, + variable, + color, + active, + disabled, + onClick, +}: { + theme: "dark" | "light"; + variable: ChartVariable; + color: string; + active: boolean; + disabled: boolean; + onClick: () => void; +}) { + const isDark = theme === "dark"; + + return ( + + ); +} + +function EmptyVariableList({ theme }: { theme: "dark" | "light" }) { + const isDark = theme === "dark"; + + return ( +
+ Nenhum sensor encontrado. +
+ ); +} + +function layoutGridClass(layoutMode: ChartLayoutMode) { + if (layoutMode === "twoColumns") return "grid gap-4 2xl:grid-cols-2"; + if (layoutMode === "fourGrid") return "grid gap-4 2xl:grid-cols-2"; + return "grid gap-4"; +} + +function getVisibleSlotCount(layoutMode: ChartLayoutMode) { + if (layoutMode === "single") return 1; + if (layoutMode === "twoColumns") return 2; + if (layoutMode === "twoRows") return 2; + return 4; +} + +function LayoutButton({ + theme, + active, + icon, + title, + onClick, +}: { + theme: "dark" | "light"; + active: boolean; + icon: React.ReactNode; + title: string; + onClick: () => void; +}) { + const isDark = theme === "dark"; + + return ( + + ); +} + +function ModeButton({ + theme, + active, + icon, + label, + onClick, +}: { + theme: "dark" | "light"; + active: boolean; + icon?: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function EmptyWorkspace({ + theme, + canAddMoreCharts, + onAddChart, + onOpenSaved, +}: { + theme: "dark" | "light"; + canAddMoreCharts: boolean; + onAddChart: () => void; + onOpenSaved: () => void; +}) { + const isDark = theme === "dark"; + + return ( +
+
+
+ +
+ +

+ Nenhum gráfico aberto +

+ +

+ Abra um gráfico guardado ou crie um novo gráfico meteorológico + para continuar. +

+ +
+ + + +
+
+
+ ); +} + +function getVariableColor(index: number) { + const colors = [ + "#4FD1C5", + "#3B82F6", + "#FACC15", + "#7DD3FC", + "#A5B4FC", + "#FB7185", + ]; + + return colors[index % colors.length]; +} + +export default MeteoChartsPage; \ No newline at end of file diff --git a/src/features/meteo/pages/MeteoPage.tsx b/src/features/meteo/pages/MeteoPage.tsx index 2bc5000..4925586 100644 --- a/src/features/meteo/pages/MeteoPage.tsx +++ b/src/features/meteo/pages/MeteoPage.tsx @@ -47,6 +47,7 @@ import { useAccumulatedHistory } from "../hooks/useAccumulatedHistory"; import type { AccumulatedBucket } from "../hooks/useAccumulatedHistory"; type MeteoPageProps = { theme: "dark" | "light"; + onOpenMeteoCharts: () => void; }; type ChartPoint = { @@ -78,7 +79,7 @@ const HISTORY_KEYS = { type HistoryKey = (typeof HISTORY_KEYS)[keyof typeof HISTORY_KEYS]; -export function MeteoPage({ theme }: MeteoPageProps) { +export function MeteoPage({ theme, onOpenMeteoCharts }: MeteoPageProps) { const { sensors } = useMeteoModuleStream(); const weatherForecast = useWeatherForecast(); const [weatherBoardOpen, setWeatherBoardOpen] = useState(false); @@ -246,9 +247,11 @@ export function MeteoPage({ theme }: MeteoPageProps) {
@@ -474,7 +477,7 @@ function formatAccumulatedValue(value: number | null, unit?: string) { if (unit === "Wh/m²") { return { - value: (value / 1000).toFixed(2), + value: value.toFixed(1), unit: "kWh/m²", }; } @@ -1153,15 +1156,18 @@ function windConsistency( function RealtimeChartPanel({ theme, + onOpenMeteoCharts, series, historyLoading, }: { theme: "dark" | "light"; + onOpenMeteoCharts: () => void; series: ChartSeries[]; historyLoading: boolean; hours: number; }) { const [mode, setMode] = useState("line"); + const [timeRange, setTimeRange] = useState("6h"); const [interval, setInterval] = useState("5m"); const [visibleKeys, setVisibleKeys] = useState([ @@ -1212,6 +1218,7 @@ function RealtimeChartPanel({ diff --git a/src/features/telemetry/hooks/useTelemetryChartSeries.ts b/src/features/telemetry/hooks/useTelemetryChartSeries.ts index b7cc886..c101905 100644 --- a/src/features/telemetry/hooks/useTelemetryChartSeries.ts +++ b/src/features/telemetry/hooks/useTelemetryChartSeries.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { WorkspaceChartInterval, WorkspaceChartPoint, @@ -20,8 +20,8 @@ export function useTelemetryChartSeries( interval: WorkspaceChartInterval, ) { const [seriesByKey, setSeriesByKey] = useState>({}); - const [loading, setLoading] = useState(true); - const [initialized, setInitialized] = useState(false); + const [loading, setLoading] = useState(false); + const initializedRef = useRef(false); const keySignature = useMemo( () => sensorKeys.slice().sort().join(","), @@ -31,20 +31,32 @@ export function useTelemetryChartSeries( useEffect(() => { if (sensorKeys.length === 0) { setSeriesByKey({}); + setLoading(false); + initializedRef.current = false; return; } const controller = new AbortController(); - async function loadHistory() { + async function loadHistory(showLoading: boolean) { + const startedAt = performance.now(); + try { const to = new Date(); const from = new Date(to.getTime() - rangeToMs(timeRange)); - if (!initialized) { + if (showLoading && !initializedRef.current) { setLoading(true); } + console.log("[TelemetryChartSeries REQUEST]", { + sensorKeys, + timeRange, + interval, + from: from.toISOString(), + to: to.toISOString(), + }); + const entries = await Promise.all( sensorKeys.map(async (key) => { const params = new URLSearchParams({ @@ -53,13 +65,15 @@ export function useTelemetryChartSeries( to: to.toISOString(), }); - const response = await fetch( - `${BACKEND_URL}/api/historian/series?${params.toString()}`, - { signal: controller.signal }, - ); + const url = `${BACKEND_URL}/api/historian/series?${params.toString()}`; + + const response = await fetch(url, { + signal: controller.signal, + cache: "no-store", + }); if (!response.ok) { - throw new Error(`Failed to load history for ${key}`); + throw new Error(`Failed to load history for ${key}: ${response.status}`); } const payload = (await response.json()) as HistorianPoint[]; @@ -74,17 +88,24 @@ export function useTelemetryChartSeries( timestamp: point.timestamp, value: point.numericValue as number, })); + return [key, aggregatePoints(points, interval)] as const; }), ); setSeriesByKey(Object.fromEntries(entries)); - setInitialized(true); + initializedRef.current = true; + + console.log("[TelemetryChartSeries DONE]", { + sensorCount: sensorKeys.length, + durationMs: Math.round(performance.now() - startedAt), + }); } catch (error) { if (controller.signal.aborted) return; - console.error("Failed to load telemetry chart history", error); - if (!initialized) { + console.error("[TelemetryChartSeries ERROR]", error); + + if (!initializedRef.current) { setSeriesByKey({}); } } finally { @@ -94,17 +115,23 @@ export function useTelemetryChartSeries( } } - loadHistory(); + loadHistory(true); + + const refreshMs = getRefreshMs(timeRange); + + if (refreshMs === null) { + return () => controller.abort(); + } const intervalId = window.setInterval(() => { - loadHistory(); - }, 10000); + loadHistory(false); + }, refreshMs); return () => { controller.abort(); window.clearInterval(intervalId); }; - }, [keySignature, timeRange, interval, initialized]); + }, [keySignature, timeRange, interval]); return { seriesByKey, @@ -112,6 +139,24 @@ export function useTelemetryChartSeries( }; } +function getRefreshMs(range: WorkspaceChartTimeRange): number | null { + switch (range) { + case "15m": + case "1h": + return 10000; + + case "6h": + return 30000; + + case "24h": + return 60000; + + case "7d": + case "30d": + return null; + } +} + function rangeToMs(range: WorkspaceChartTimeRange) { switch (range) { case "15m": @@ -147,26 +192,22 @@ function aggregatePoints( interval: WorkspaceChartInterval, ): WorkspaceChartPoint[] { const bucketMs = intervalToMs(interval); - - if (bucketMs === 0) { - return points; - } - const buckets = new Map(); for (const point of points) { const time = new Date(point.timestamp).getTime(); - if (!Number.isFinite(time)) { - continue; - } + if (!Number.isFinite(time)) continue; const bucketTime = Math.floor(time / bucketMs) * bucketMs; - const values = buckets.get(bucketTime) ?? []; - if (point.value !== null && Number.isFinite(point.value)) { - values.push(point.value); + + const value = point.value; + + if (value !== null && Number.isFinite(value)) { + values.push(value); } + buckets.set(bucketTime, values); } @@ -175,9 +216,12 @@ function aggregatePoints( .map(([bucketTime, values]) => ({ timestamp: new Date(bucketTime).toISOString(), value: average(values), - })); + })) + .filter((point) => Number.isFinite(point.value)); } function average(values: number[]) { + if (values.length === 0) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; } \ No newline at end of file diff --git a/src/features/telemetry/hooks/useTelemetryStream.ts b/src/features/telemetry/hooks/useTelemetryStream.ts index 7ee4e9a..f0f5628 100644 --- a/src/features/telemetry/hooks/useTelemetryStream.ts +++ b/src/features/telemetry/hooks/useTelemetryStream.ts @@ -2,29 +2,73 @@ import { useEffect, useState } from "react"; import { Client } from "@stomp/stompjs"; import type { TelemetryBroadcastMessage } from "../../../types/telemetry"; +const BACKEND_URL = "http://localhost:18450"; + export function useTelemetryStream() { const [message, setMessage] = useState(null); const [connected, setConnected] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); useEffect(() => { + const controller = new AbortController(); + + async function loadInitialLatest() { + try { + const response = await fetch(`${BACKEND_URL}/api/telemetry/latest`, { + signal: controller.signal, + cache: "no-store", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to load latest telemetry: ${response.status}`); + } + + const payload = (await response.json()) as TelemetryBroadcastMessage; + + console.log("[TelemetryStream INITIAL]", payload); + + setMessage(payload); + } catch (error) { + if (controller.signal.aborted) return; + + console.error("[TelemetryStream INITIAL ERROR]", error); + } finally { + if (!controller.signal.aborted) { + setInitialLoading(false); + } + } + } + + loadInitialLatest(); + const client = new Client({ brokerURL: "ws://localhost:18450/ws", reconnectDelay: 3000, onConnect: () => { + console.log("[TelemetryStream WS CONNECTED]"); setConnected(true); client.subscribe("/topic/telemetry/latest", (frame) => { const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage; + + console.log("[TelemetryStream WS MESSAGE]", payload); + setMessage(payload); + setInitialLoading(false); }); }, onWebSocketClose: () => { + console.warn("[TelemetryStream WS CLOSED]"); setConnected(false); }, - onStompError: () => { + onStompError: (frame) => { + console.error("[TelemetryStream STOMP ERROR]", frame); setConnected(false); }, }); @@ -32,12 +76,14 @@ export function useTelemetryStream() { client.activate(); return () => { - client.deactivate(); + controller.abort(); + void client.deactivate(); }; }, []); return { connected, + initialLoading, message, lastTimestamp: message?.timestamp ?? null, snapshots: message?.snapshots ?? [],