From ffe3c64cfa87e85fd3dfb6ce57c24c513db4bcea Mon Sep 17 00:00:00 2001 From: litoral05 Date: Wed, 27 May 2026 14:38:25 +0100 Subject: [PATCH] feat(charts): add persistent workspace chart management --- src/app/App.tsx | 67 +- src/components/charts/WorkspaceChart.tsx | 1065 +++++++++++ src/components/layout/TopBar.tsx | 26 + src/components/navigation/Sidebar.tsx | 104 +- .../climate/pages/ClimateChartsPage.tsx | 360 ++++ src/features/console/pages/ConsolePage.tsx | 267 +++ .../dashboard/pages/DashboardPage.tsx | 172 +- .../hooks/useChartWorkspacePersistence.ts | 167 ++ .../maincharts/pages/MainChartsPage.tsx | 1585 +++++++++++++++++ .../components/AccumulatedHistoryModal.tsx | 2 +- .../meteo/components/MeteoHistoryModal.tsx | 2 +- .../meteo/components/WeatherForecastCard.tsx | 2 - .../meteo/domain/meteoSensorSelectors.ts | 103 ++ .../meteo/hooks/useAccumulatedHistory.ts | 2 +- src/features/meteo/hooks/useMeteoHistory.ts | 2 +- src/features/meteo/pages/MeteoPage.tsx | 104 +- src/features/settings/pages/SettingsPage.tsx | 245 +++ .../telemetry/hooks/useTelemetryCatalog.ts | 23 + .../hooks/useTelemetryChartSeries.ts | 183 ++ .../telemetry/types/telemetryCatalog.ts | 64 + .../telemetry/utils/telemetryLookup.ts | 36 +- src/types/meteo.ts | 4 +- src/types/telemetry.ts | 24 +- 23 files changed, 4407 insertions(+), 202 deletions(-) create mode 100644 src/components/charts/WorkspaceChart.tsx create mode 100644 src/features/climate/pages/ClimateChartsPage.tsx create mode 100644 src/features/console/pages/ConsolePage.tsx create mode 100644 src/features/maincharts/hooks/useChartWorkspacePersistence.ts create mode 100644 src/features/maincharts/pages/MainChartsPage.tsx create mode 100644 src/features/meteo/domain/meteoSensorSelectors.ts create mode 100644 src/features/settings/pages/SettingsPage.tsx create mode 100644 src/features/telemetry/hooks/useTelemetryCatalog.ts create mode 100644 src/features/telemetry/hooks/useTelemetryChartSeries.ts create mode 100644 src/features/telemetry/types/telemetryCatalog.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 7313d72..493b18d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,25 +1,74 @@ import { useState } from "react"; + import { AppShell } from "../components/layout/AppShell"; + import { DashboardPage } from "../features/dashboard/pages/DashboardPage"; import { MeteoPage } from "../features/meteo/pages/MeteoPage"; +import { ClimateChartsPage } from "../features/climate/pages/ClimateChartsPage"; +import { ConsolePage } from "../features/console/pages/ConsolePage"; +import { MainChartsPage } from "../features/maincharts/pages/MainChartsPage"; +import { SettingsPage } from "../features/settings/pages/SettingsPage"; -export type AppPage = "dashboard" | "meteo"; +export type AppPage = + | "dashboard" + | "meteo" + | "console" + | "maincharts" + | "settings" + + // CLIMATE + | "climate" + | "climateCharts" + | "climateLighting" + | "climateVentilation" + | "climateSynoptic" + + // IRRIGATION + | "irrigation" + | "irrigationCharts" + | "irrigationFilters" + | "irrigationConsumption" + | "irrigationDrainage" + | "irrigationSynoptic"; function App() { - const [activePage, setActivePage] = useState("dashboard"); + const [activePage, setActivePage] = + useState("dashboard"); return ( - - {({ theme }) => - activePage === "meteo" ? ( - - ) : ( + + {({ theme }) => { + if (activePage === "meteo") { + return ; + } + + if (activePage === "climateCharts") { + return ; + } + + + if (activePage === "console") { + return ; + } + + if (activePage === "maincharts") { + return ; + } + + if (activePage === "settings") { + return + } + return ( setActivePage("meteo")} + onNavigate={setActivePage} /> - ) - } + ); + }} ); } diff --git a/src/components/charts/WorkspaceChart.tsx b/src/components/charts/WorkspaceChart.tsx new file mode 100644 index 0000000..7c09412 --- /dev/null +++ b/src/components/charts/WorkspaceChart.tsx @@ -0,0 +1,1065 @@ +import { useMemo, useState } from "react"; +import { + Activity, + Loader2, + ChevronDown, + Download, + Maximize2, + Save, + Settings2, + SlidersHorizontal, + type LucideIcon, +} from "lucide-react"; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +export type WorkspaceChartMode = "line" | "area" | "bar"; + +export type WorkspaceChartTimeRange = "15m" | "1h" | "6h" | "24h" | "7d" | "30d"; + +export type WorkspaceChartInterval = "1m" | "5m" | "15m" | "1h"; + +export type WorkspaceChartPoint = { + timestamp: string; + value: number | null; +}; + +export type WorkspaceChartVariable = { + key: string; + label: string; + unit?: string; + color: string; + data: WorkspaceChartPoint[]; + visible: boolean; + limit?: number; +}; + +export type WorkspaceChartConfig = { + id: string; + title: string; + subtitle: string; + icon: LucideIcon; + status?: "online" | "offline"; + sourceLabel?: string; + mode: WorkspaceChartMode; + timeRange: WorkspaceChartTimeRange; + interval: WorkspaceChartInterval; + variables: WorkspaceChartVariable[]; +}; + +type Props = { + theme: "dark" | "light"; + chart: WorkspaceChartConfig; + detached?: boolean; + dragHandle?: React.ReactNode; + onHeaderPointerDown?: (event: React.PointerEvent) => void; + onModeChange: (mode: WorkspaceChartMode) => void; + onVariableToggle: (variableKey: string) => void; + onTimeRangeChange: (range: WorkspaceChartTimeRange) => void; + onIntervalChange: (interval: WorkspaceChartInterval) => void; + onSave?: () => void; + onExport?: () => void; + onDetach?: () => void; + onAttach?: () => void; + loading?: boolean; + configuredVariableCount?: number; +}; + +type ChartRow = { + time: string; + timestamp: string; + [key: string]: string | number | null | undefined; +}; + +type YAxisConfig = { + id: string; + unit: string; + orientation: "left" | "right"; + color: string; +}; + +const RADIUS = "rounded-[8px]"; + +const RANGE_OPTIONS: { value: WorkspaceChartTimeRange; label: string }[] = [ + { value: "15m", label: "15M" }, + { value: "1h", label: "1H" }, + { value: "6h", label: "6H" }, + { value: "24h", label: "24H" }, + { value: "7d", label: "7D" }, + { value: "30d", label: "30D" }, +]; + +const INTERVAL_OPTIONS: { value: WorkspaceChartInterval; label: string }[] = [ + { value: "1m", label: "1m" }, + { value: "5m", label: "5m" }, + { value: "15m", label: "15m" }, + { value: "1h", label: "1h" }, +]; + +export function WorkspaceChart({ + theme, + chart, + loading = false, + detached = false, + dragHandle, + onHeaderPointerDown, + onModeChange, + onVariableToggle, + onTimeRangeChange, + onIntervalChange, + onSave, + onExport, + onDetach, + onAttach, + configuredVariableCount = chart.variables.length +}: Props) { + const isDark = theme === "dark"; + + const [rangeMenuOpen, setRangeMenuOpen] = useState(false); + const [intervalMenuOpen, setIntervalMenuOpen] = useState(false); + const [modeMenuOpen, setModeMenuOpen] = useState(false); + const [variableMenuOpen, setVariableMenuOpen] = useState(false); + const [showIndicators, setShowIndicators] = useState(true); + const [zeroBaseline, setZeroBaseline] = useState(false); + + const visibleVariables = useMemo( + () => chart.variables.filter((variable) => variable.visible), + [chart.variables], + ); + + const hasConfiguredVariables = configuredVariableCount > 0; + const shouldShowLoading = loading && hasConfiguredVariables; + + const primaryVariable = visibleVariables[0] ?? null; + + const yAxes = useMemo( + () => buildYAxisConfigs(visibleVariables), + [visibleVariables], + ); + + const data = useMemo(() => { + const fromTime = Date.now() - rangeToMs(chart.timeRange); + const bucketMs = intervalToMs(chart.interval); + + const buckets = new Map< + number, + { + timestamp: string; + values: Record; + } + >(); + + for (const variable of chart.variables) { + for (const point of variable.data) { + + if (point.value === null) { + continue; + } + const pointTime = new Date(point.timestamp).getTime(); + + if (!Number.isFinite(pointTime) || pointTime < fromTime) { + continue; + } + + const bucketTime = + bucketMs === 0 + ? pointTime + : Math.floor(pointTime / bucketMs) * bucketMs; + + const existing = buckets.get(bucketTime); + + if (existing) { + existing.values[variable.key] ??= []; + existing.values[variable.key].push(point.value); + } else { + buckets.set(bucketTime, { + timestamp: new Date(bucketTime).toISOString(), + values: { + [variable.key]: [point.value], + }, + }); + } + } + } + + return Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([_, bucket]) => { + const row: ChartRow = { + timestamp: bucket.timestamp, + time: formatAxisTime(bucket.timestamp, chart.timeRange), + }; + + for (const variable of chart.variables) { + const values = bucket.values[variable.key]; + + row[variable.key] = + values && values.length > 0 + ? average(values) + : null; + } + + return row; + }); + }, [chart.variables, chart.timeRange, chart.interval]); + + const stats = useMemo(() => { + if (!primaryVariable) { + return { + current: null, + average: null, + max: null, + min: null, + change: null, + count: 0, + }; + } + + const values = primaryVariable.data + .map((point) => point.value) + .filter( + (value): value is number => + typeof value === "number" && + Number.isFinite(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, + }; + }, [primaryVariable]); + + const yDomain: [number | "auto", number | "auto"] = zeroBaseline + ? [0, "auto"] + : ["auto", "auto"]; + + const allowedIntervalOptions = useMemo(() => { + if (chart.mode === "bar") { + return INTERVAL_OPTIONS.filter((option) => + ["5m", "15m", "1h"].includes(option.value), + ); + } + + return INTERVAL_OPTIONS; + }, [chart.mode]); + + return ( +
+
+
+ {dragHandle &&
{dragHandle}
} + +
+
+

+ {chart.title} +

+ + +
+ +

+ {chart.subtitle} +

+
+
+ +
+ {onSave && ( + + )} + + {onExport && ( + + )} + + {onDetach && onAttach && ( + + )} +
+
+ +
+
+
+
+ + + {rangeMenuOpen && ( + + {RANGE_OPTIONS.map((range) => ( + + ))} + + )} +
+ +
+
+ + + {intervalMenuOpen && ( + + {allowedIntervalOptions.map((interval) => ( + + ))} + + )} +
+ +
+ + + {modeMenuOpen && ( + + {(["line", "area", "bar"] as WorkspaceChartMode[]).map( + (mode) => ( + + ), + )} + + )} +
+ + + +
+ + + {variableMenuOpen && ( + +

+ Variáveis registadas +

+ +
+ {chart.variables.map((variable) => ( + + ))} +
+
+ )} +
+ + +
+
+ +
+
+ {visibleVariables.map((variable) => ( + + + {variable.label} + {variable.unit && ` (${variable.unit})`} + + ))} +
+ + {primaryVariable && showIndicators && ( +
+ + Média:{" "} + {formatValue(stats.average, primaryVariable.unit)} + + + Máx:{" "} + {formatValue(stats.max, primaryVariable.unit)} + + + Mín:{" "} + {formatValue(stats.min, primaryVariable.unit)} + +
+ )} +
+ +
+ {visibleVariables.length === 0 && !shouldShowLoading ? ( + + ) : data.length === 0 && !shouldShowLoading ? ( + + ) : data.length > 0 ? ( + + {chart.mode === "bar" ? ( + + + {visibleVariables.map((variable) => ( + + ))} + + ) : chart.mode === "area" ? ( + + + {renderReferenceLines( + visibleVariables, + showIndicators, + stats.average, + )} + {visibleVariables.map((variable) => ( + + ))} + + ) : ( + + + {renderReferenceLines( + visibleVariables, + showIndicators, + stats.average, + )} + {visibleVariables.map((variable) => ( + + ))} + + )} + + ) : null} + + {shouldShowLoading && ( +
+
+ + A carregar histórico... +
+
+ )} +
+ + {showIndicators && primaryVariable && ( +
+
+ + + + + +
+
+ )} +
+
+
+ ); +} + +function ChartScaffold({ + isDark, + yDomain, + yAxes, +}: { + isDark: boolean; + yDomain: [number | "auto", number | "auto"]; + yAxes: YAxisConfig[]; +}) { + return ( + <> + + + + + {yAxes.map((axis) => ( + + ))} + + { + const timestamp = payload?.[0]?.payload?.timestamp; + return timestamp ? formatTooltipDate(timestamp) : ""; + }} + contentStyle={{ + background: isDark ? "#111827" : "#ffffff", + border: isDark + ? "1px solid rgba(255,255,255,0.10)" + : "1px solid #e2e8f0", + borderRadius: 8, + color: isDark ? "#e2e8f0" : "#0f172a", + boxShadow: "0 16px 34px rgba(0,0,0,0.24)", + }} + /> + + ); +} + +function DropdownPanel({ + isDark, + className, + children, +}: { + isDark: boolean; + className: string; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +function EmptyChartMessage({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function renderReferenceLines( + visibleVariables: WorkspaceChartVariable[], + showIndicators: boolean, + average: number | null, +) { + return ( + <> + {showIndicators && average !== null && ( + + )} + + {visibleVariables.map((variable) => + variable.limit !== undefined ? ( + + ) : null, + )} + + ); +} + +function InlineMetric({ + label, + value, + isDark, +}: { + label: string; + value: string; + isDark: boolean; +}) { + return ( + + {label}:{" "} + + {value} + + + ); +} + +function buttonClass(isDark: boolean) { + return isDark + ? `${RADIUS} h-8 shrink-0 whitespace-nowrap border border-[#24334D] bg-white/[0.025] px-3 text-[11px] font-semibold text-slate-300 transition hover:bg-white/[0.06] hover:text-slate-100` + : `${RADIUS} h-8 shrink-0 whitespace-nowrap border border-slate-200 bg-white px-3 text-[11px] font-semibold text-slate-600 transition hover:bg-slate-100 hover:text-slate-950`; +} + +function activeButtonClass(isDark: boolean) { + return isDark + ? `${RADIUS} h-8 shrink-0 whitespace-nowrap border border-[#4FD1C5] bg-[#4FD1C5] px-3 text-[11px] font-black text-[#07101B]` + : `${RADIUS} h-8 shrink-0 whitespace-nowrap border border-[#0F766E] bg-[#0F766E] px-3 text-[11px] font-black text-white`; +} + +function iconButtonClass(isDark: boolean, active = false) { + if (active) { + return isDark + ? `${RADIUS} flex h-8 w-8 shrink-0 items-center justify-center border border-[#4FD1C5] bg-[#4FD1C5] text-[#07101B]` + : `${RADIUS} flex h-8 w-8 shrink-0 items-center justify-center border border-[#0F766E] bg-[#0F766E] text-white`; + } + + return isDark + ? `${RADIUS} flex h-8 w-8 shrink-0 items-center justify-center border border-[#24334D] bg-white/[0.025] text-slate-400 transition hover:bg-white/[0.06] hover:text-slate-100` + : `${RADIUS} flex h-8 w-8 shrink-0 items-center justify-center border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-100 hover:text-slate-950`; +} + +function windowButtonClass(isDark: boolean) { + return isDark + ? `${RADIUS} p-2 text-slate-500 transition hover:bg-white/5 hover:text-white` + : `${RADIUS} p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900`; +} + +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}` : ""}`; +} + +function formatRangeLabel(range: WorkspaceChartTimeRange) { + return range.toUpperCase(); +} + +function formatIntervalLabel(interval: WorkspaceChartInterval) { + return interval; +} + +function formatModeLabel(mode: WorkspaceChartMode) { + if (mode === "line") return "Linha"; + if (mode === "area") return "Área"; + return "Barras"; +} + +function formatAxisTime(timestamp: string, range: WorkspaceChartTimeRange) { + const date = new Date(timestamp); + + if (range === "7d" || range === "30d") { + return date.toLocaleDateString("pt-PT", { + day: "2-digit", + month: "2-digit", + }); + } + + return date.toLocaleTimeString("pt-PT", { + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatTooltipDate(timestamp: string) { + return new Date(timestamp).toLocaleString("pt-PT", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function rangeToMs(range: WorkspaceChartTimeRange) { + switch (range) { + case "15m": + return 15 * 60 * 1000; + case "1h": + return 60 * 60 * 1000; + case "6h": + return 6 * 60 * 60 * 1000; + case "24h": + return 24 * 60 * 60 * 1000; + case "7d": + return 7 * 24 * 60 * 60 * 1000; + case "30d": + return 30 * 24 * 60 * 60 * 1000; + } +} + +function intervalToMs(interval: WorkspaceChartInterval) { + switch (interval) { + case "1m": + return 60 * 1000; + case "5m": + return 5 * 60 * 1000; + case "15m": + return 15 * 60 * 1000; + case "1h": + return 60 * 60 * 1000; + } +} + +function average(values: number[]) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function buildYAxisConfigs( + variables: WorkspaceChartVariable[], +): YAxisConfig[] { + const uniqueUnits = Array.from( + new Set( + variables.map((variable) => normalizeUnit(variable.unit)), + ), + ); + + const limitedUnits = uniqueUnits.slice(0, 2); + + if (limitedUnits.length === 0) { + return [ + { + id: "axis-default", + unit: "", + orientation: "left", + color: "#64748b", + }, + ]; + } + + return limitedUnits.map((unit, index) => { + const variable = variables.find( + (item) => normalizeUnit(item.unit) === unit, + ); + + return { + id: axisIdForUnit(unit), + unit, + orientation: index === 0 ? "left" : "right", + color: variable?.color ?? "#64748b", + }; + }); +} + +function getYAxisId( + variable: WorkspaceChartVariable, + axes: YAxisConfig[], +): string { + const unit = normalizeUnit(variable.unit); + + return ( + axes.find((axis) => axis.unit === unit)?.id ?? + axes[0]?.id ?? + "axis-default" + ); +} + +function axisIdForUnit(unit: string): string { + return `axis-${unit || "default"}`; +} + +function normalizeUnit(unit?: string): string { + return unit?.trim() ?? ""; +} +export default WorkspaceChart; \ No newline at end of file diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index be167ea..614c551 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -296,9 +296,35 @@ function pageTitle(page: AppPage | null) { case "dashboard": return ""; + case "maincharts": + return "Gráficos Gerais"; + case "meteo": return "Meteorologia"; + case "settings": + return "Configurações"; + + case "console": + return "Consola (VNC)"; + + // ALL CLIMATE PAGES + case "climate": + case "climateCharts": + case "climateLighting": + case "climateVentilation": + case "climateSynoptic": + return "Clima"; + + // ALL IRRIGATION / REGA PAGES + case "irrigation": + case "irrigationCharts": + case "irrigationFilters": + case "irrigationConsumption": + case "irrigationDrainage": + case "irrigationSynoptic": + return "Rega"; + default: return "Painel Principal"; } diff --git a/src/components/navigation/Sidebar.tsx b/src/components/navigation/Sidebar.tsx index c1b46ab..b86e971 100644 --- a/src/components/navigation/Sidebar.tsx +++ b/src/components/navigation/Sidebar.tsx @@ -28,7 +28,7 @@ type SidebarProps = { onToggleCollapsed: () => void; }; -const RADIUS = "rounded-[10px]"; +const RADIUS = "rounded-[6px]"; const navigationItems: { label: string; @@ -37,6 +37,7 @@ const navigationItems: { }[] = [ { label: "Painel Principal", page: "dashboard", icon: Home }, { label: "Meteorologia", page: "meteo", icon: CloudSun }, + { label: "Gráficos Gerais", page: "maincharts", icon: BarChart3 } ]; const climateItems = [ @@ -55,10 +56,14 @@ const irrigationItems = [ { label: "Gráficos", icon: BarChart3 }, ]; -const utilityItems = [ - { label: "Consola (VNC)", icon: TabletSmartphone }, - { label: "Configurações", icon: Settings }, -]; +const utilityItems: { + label: string; + page: AppPage; + icon: React.ElementType; +}[] = [ + { label: "Consola (VNC)", page: "console", icon: TabletSmartphone }, + { label: "Configurações", page: "settings", icon: Settings }, + ]; export function Sidebar({ theme, @@ -75,6 +80,10 @@ export function Sidebar({ const handleTreeClick = (key: string) => { setActiveTreeItem(key); + + if (key === "climate:Gráficos") { + onNavigate("climateCharts"); + } }; const handleTreeToggle = (section: "climate" | "irrigation") => { @@ -96,28 +105,30 @@ export function Sidebar({