Implements meteoChartsPage, fixed responsiveness overall

This commit is contained in:
litoral05
2026-06-01 12:07:56 +01:00
parent 540e4ed560
commit 6e44522782
21 changed files with 2276 additions and 881 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
"main", "main",
"chart-*", "chart-*",
"maincharts-*", "maincharts-*",
"climatecharts-*" "climatecharts-*",
"meteocharts-*"
], ],
"permissions": [ "permissions": [
"core:default", "core:default",
+17 -1
View File
@@ -10,6 +10,7 @@ import { MainChartsPage } from "../features/maincharts/pages/MainChartsPage";
import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPage"; import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPage";
import { SettingsPage } from "../features/settings/pages/SettingsPage"; import { SettingsPage } from "../features/settings/pages/SettingsPage";
import SynopticPage from "../features/synoptic/pages/SynopticPage"; import SynopticPage from "../features/synoptic/pages/SynopticPage";
import MeteoChartsPage from "../features/meteo/pages/MeteoChartsPage";
export type AppPage = export type AppPage =
| "dashboard" | "dashboard"
@@ -44,7 +45,22 @@ function App() {
return ( return (
<AppShell activePage={activePage} onNavigate={setActivePage}> <AppShell activePage={activePage} onNavigate={setActivePage}>
{({ theme }) => { {({ theme }) => {
if (activePage === "meteo") return <MeteoPage theme={theme} />; if (activePage === "meteo") {
return (
<MeteoPage
theme={theme}
onOpenMeteoCharts={() => setActivePage("meteoCharts")}
/>
);
}
if (activePage === "meteoCharts") {
return (
<MeteoChartsPage
theme={theme}
/>
);
}
if (activePage === "climateCharts") { if (activePage === "climateCharts") {
return <ClimateChartsPage theme={theme} />; return <ClimateChartsPage theme={theme} />;
-108
View File
@@ -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 (
<div
className={
isDark
? "group relative overflow-hidden rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)] transition hover:border-white/20"
: "group relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-slate-300 hover:shadow-md"
}
>
<div
className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${accentClass.glow} via-transparent to-transparent opacity-60`}
/>
<div className="relative flex items-center gap-4">
<div
className={`flex h-14 w-14 items-center justify-center rounded-2xl ${accentClass.bg} ${accentClass.icon}`}
>
<Icon className="h-8 w-8 stroke-[1.8]" />
</div>
<div className="min-w-0 flex-1">
<p
className={
isDark
? "mb-2 truncate text-sm font-medium text-slate-300"
: "mb-2 truncate text-sm font-medium text-slate-600"
}
>
{title}
</p>
<div className="flex items-end gap-2">
<span
className={
isDark
? "text-3xl font-bold leading-none tracking-tight text-white"
: "text-3xl font-bold leading-none tracking-tight text-slate-950"
}
>
{value}
</span>
{unit && (
<span
className={
isDark
? "mb-1 text-sm font-semibold text-slate-300"
: "mb-1 text-sm font-semibold text-slate-600"
}
>
{unit}
</span>
)}
</div>
</div>
</div>
</div>
);
}
+63 -36
View File
@@ -81,6 +81,7 @@ type Props = {
type ChartRow = { type ChartRow = {
time: string; time: string;
timestamp: string; timestamp: string;
timestampMs: number;
[key: string]: string | number | null | undefined; [key: string]: string | number | null | undefined;
}; };
@@ -200,6 +201,7 @@ export function WorkspaceChart({
.map(([_, bucket]) => { .map(([_, bucket]) => {
const row: ChartRow = { const row: ChartRow = {
timestamp: bucket.timestamp, timestamp: bucket.timestamp,
timestampMs: new Date(bucket.timestamp).getTime(),
time: formatAxisTime(bucket.timestamp, chart.timeRange), time: formatAxisTime(bucket.timestamp, chart.timeRange),
}; };
@@ -216,6 +218,11 @@ export function WorkspaceChart({
}); });
}, [chart.variables, chart.timeRange, chart.interval]); }, [chart.variables, chart.timeRange, chart.interval]);
const displayData = useMemo(
() => downsampleRows(data, maxRowsForRange(chart.timeRange)),
[data, chart.timeRange],
);
const stats = useMemo(() => { const stats = useMemo(() => {
if (!primaryVariable) { if (!primaryVariable) {
return { return {
@@ -279,16 +286,16 @@ export function WorkspaceChart({
className={ className={
detached detached
? isDark ? isDark
? `${RADIUS} flex h-full 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-[#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-white text-slate-950`
: isDark : isDark
? `${RADIUS} 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-[#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-slate-200 bg-white text-slate-950 shadow-[0_14px_34px_rgba(15,23,42,0.08)]`
} }
> >
<header <header
onPointerDown={onHeaderPointerDown} onPointerDown={onHeaderPointerDown}
className="flex items-start justify-between gap-4 px-4 py-4 sm:px-5" className="flex shrink-0 items-start justify-between gap-3 px-3 py-3 sm:px-4"
> >
<div className="flex min-w-0 flex-1 items-start gap-3"> <div className="flex min-w-0 flex-1 items-start gap-3">
{dragHandle && <div className="mt-1 shrink-0">{dragHandle}</div>} {dragHandle && <div className="mt-1 shrink-0">{dragHandle}</div>}
@@ -356,25 +363,15 @@ export function WorkspaceChart({
</div> </div>
</header> </header>
<main <main className="flex min-h-0 flex-1 flex-col px-3 pb-3 sm:px-4 sm:pb-4">
className={
detached
? "flex min-h-0 flex-1 flex-col px-4 pb-4 sm:px-5 sm:pb-5"
: "px-4 pb-4 sm:px-5 sm:pb-5"
}
>
<section <section
className={ className={
detached isDark
? isDark ? `${RADIUS} flex min-h-0 flex-1 flex-col bg-[#09111F] p-2 sm:p-3`
? `${RADIUS} flex min-h-0 flex-1 flex-col bg-[#09111F] p-3 sm:p-4` : `${RADIUS} flex min-h-0 flex-1 flex-col bg-slate-50 p-2 sm:p-3`
: `${RADIUS} flex min-h-0 flex-1 flex-col bg-slate-50 p-3 sm:p-4`
: isDark
? `${RADIUS} bg-[#09111F] p-3 sm:p-4`
: `${RADIUS} bg-slate-50 p-3 sm:p-4`
} }
> >
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-2 flex shrink-0 items-center justify-between gap-2">
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
@@ -572,7 +569,7 @@ export function WorkspaceChart({
</div> </div>
</div> </div>
<div className="mb-3 flex items-center justify-between gap-4 overflow-x-auto pb-1"> <div className="mb-2 flex shrink-0 items-center justify-between gap-3 overflow-x-auto pb-1">
<div className="flex shrink-0 items-center gap-4 text-[11px]"> <div className="flex shrink-0 items-center gap-4 text-[11px]">
{visibleVariables.map((variable) => ( {visibleVariables.map((variable) => (
<span <span
@@ -611,13 +608,7 @@ export function WorkspaceChart({
)} )}
</div> </div>
<div <div className="relative min-h-[80px] flex-1">
className={
detached
? "relative min-h-0 flex-1"
: "relative h-[340px] min-h-[340px]"
}
>
{visibleVariables.length === 0 && !shouldShowLoading ? ( {visibleVariables.length === 0 && !shouldShowLoading ? (
<EmptyChartMessage message="Escolha pelo menos uma variável." /> <EmptyChartMessage message="Escolha pelo menos uma variável." />
) : data.length === 0 && !shouldShowLoading ? ( ) : data.length === 0 && !shouldShowLoading ? (
@@ -625,11 +616,12 @@ export function WorkspaceChart({
) : data.length > 0 ? ( ) : data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
{chart.mode === "bar" ? ( {chart.mode === "bar" ? (
<BarChart data={data}> <BarChart data={displayData} barCategoryGap="25%" barGap={4}>
<ChartScaffold <ChartScaffold
isDark={isDark} isDark={isDark}
yDomain={yDomain} yDomain={yDomain}
yAxes={yAxes} yAxes={yAxes}
chartTimeRange={chart.timeRange}
/> />
{visibleVariables.map((variable) => ( {visibleVariables.map((variable) => (
<Bar <Bar
@@ -642,15 +634,17 @@ export function WorkspaceChart({
radius={[3, 3, 0, 0]} radius={[3, 3, 0, 0]}
opacity={0.78} opacity={0.78}
isAnimationActive={false} isAnimationActive={false}
maxBarSize={42}
/> />
))} ))}
</BarChart> </BarChart>
) : chart.mode === "area" ? ( ) : chart.mode === "area" ? (
<AreaChart data={data}> <AreaChart data={displayData}>
<ChartScaffold <ChartScaffold
isDark={isDark} isDark={isDark}
yDomain={yDomain} yDomain={yDomain}
yAxes={yAxes} yAxes={yAxes}
chartTimeRange={chart.timeRange}
/> />
{renderReferenceLines( {renderReferenceLines(
visibleVariables, visibleVariables,
@@ -677,11 +671,12 @@ export function WorkspaceChart({
))} ))}
</AreaChart> </AreaChart>
) : ( ) : (
<LineChart data={data}> <LineChart data={displayData}>
<ChartScaffold <ChartScaffold
isDark={isDark} isDark={isDark}
yDomain={yDomain} yDomain={yDomain}
yAxes={yAxes} yAxes={yAxes}
chartTimeRange={chart.timeRange}
/> />
{renderReferenceLines( {renderReferenceLines(
visibleVariables, visibleVariables,
@@ -720,7 +715,7 @@ export function WorkspaceChart({
</div> </div>
{showIndicators && primaryVariable && ( {showIndicators && primaryVariable && (
<div className="mt-3 shrink-0 overflow-x-auto border-t border-white/[0.06] pt-3"> <div className="mt-2 shrink-0 overflow-x-auto border-t border-white/[0.06] pt-2">
<div className="flex min-w-max items-center gap-5 text-xs"> <div className="flex min-w-max items-center gap-5 text-xs">
<InlineMetric <InlineMetric
label="Atual" label="Atual"
@@ -766,10 +761,12 @@ function ChartScaffold({
isDark, isDark,
yDomain, yDomain,
yAxes, yAxes,
chartTimeRange
}: { }: {
isDark: boolean; isDark: boolean;
yDomain: [number | "auto", number | "auto"]; yDomain: [number | "auto", number | "auto"];
yAxes: YAxisConfig[]; yAxes: YAxisConfig[];
chartTimeRange: WorkspaceChartTimeRange;
}) { }) {
return ( return (
<> <>
@@ -780,15 +777,21 @@ function ChartScaffold({
/> />
<XAxis <XAxis
dataKey="time" dataKey="timestampMs"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
padding={{ left: 24, right: 24 }}
tick={{ fill: "#64748b", fontSize: 10 }} tick={{ fill: "#64748b", fontSize: 10 }}
tickLine={false} tickLine={false}
axisLine={{ axisLine={{
stroke: isDark ? "rgba(148,163,184,0.12)" : "#cbd5e1", stroke: isDark ? "rgba(148,163,184,0.12)" : "#cbd5e1",
}} }}
minTickGap={34} minTickGap={34}
tickFormatter={(value) =>
formatAxisTime(new Date(Number(value)).toISOString(), chartTimeRange)
}
/> />
{yAxes.map((axis) => ( {yAxes.map((axis) => (
<YAxis <YAxis
key={axis.id} key={axis.id}
@@ -985,7 +988,15 @@ function formatModeLabel(mode: WorkspaceChartMode) {
function formatAxisTime(timestamp: string, range: WorkspaceChartTimeRange) { function formatAxisTime(timestamp: string, range: WorkspaceChartTimeRange) {
const date = new Date(timestamp); const date = new Date(timestamp);
if (range === "7d" || range === "30d") { if (range === "7d") {
return date.toLocaleString("pt-PT", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
});
}
if (range === "30d") {
return date.toLocaleDateString("pt-PT", { return date.toLocaleDateString("pt-PT", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
@@ -997,7 +1008,6 @@ function formatAxisTime(timestamp: string, range: WorkspaceChartTimeRange) {
minute: "2-digit", minute: "2-digit",
}); });
} }
function formatTooltipDate(timestamp: string) { function formatTooltipDate(timestamp: string) {
return new Date(timestamp).toLocaleString("pt-PT", { return new Date(timestamp).toLocaleString("pt-PT", {
day: "2-digit", day: "2-digit",
@@ -1099,4 +1109,21 @@ function normalizeUnit(unit?: string): string {
return displayUnit(unit); return displayUnit(unit);
} }
function maxRowsForRange(range: WorkspaceChartTimeRange) {
if (range === "15m") return 900;
if (range === "1h") return 900;
if (range === "6h") return 1200;
if (range === "24h") return 1600;
if (range === "7d") return 2500;
return 3000;
}
function downsampleRows(rows: ChartRow[], maxRows: number) {
if (rows.length <= maxRows) return rows;
const step = Math.ceil(rows.length / maxRows);
return rows.filter((_, index) => index % step === 0);
}
export default WorkspaceChart; export default WorkspaceChart;
+21 -12
View File
@@ -34,6 +34,7 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
}); });
const isDark = theme === "dark"; const isDark = theme === "dark";
const isDashboard = activePage === "dashboard";
useEffect(() => { useEffect(() => {
localStorage.setItem(THEME_STORAGE_KEY, theme); localStorage.setItem(THEME_STORAGE_KEY, theme);
@@ -47,14 +48,14 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
<div <div
className={ className={
isDark isDark
? "fixed inset-0 overflow-hidden bg-[#0b1220] text-slate-100" ? "fixed inset-0 overflow-hidden bg-[#07101B] text-slate-100"
: "fixed inset-0 overflow-hidden bg-slate-100 text-slate-950" : "fixed inset-0 overflow-hidden bg-white text-slate-950"
} }
> >
<style>{` <style>{`
.app-scrollbar { .app-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: ${isDark ? "#334155 #0b1220" : "#94a3b8 #f1f5f9"}; scrollbar-color: ${isDark ? "#334155 #07101B" : "#94a3b8 #f8fafc"};
} }
.app-scrollbar::-webkit-scrollbar { .app-scrollbar::-webkit-scrollbar {
@@ -63,14 +64,14 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
} }
.app-scrollbar::-webkit-scrollbar-track { .app-scrollbar::-webkit-scrollbar-track {
background: ${isDark ? "#0b1220" : "#f1f5f9"}; background: ${isDark ? "#07101B" : "#f8fafc"};
border-left: 1px solid ${isDark ? "rgba(255,255,255,0.06)" : "#e2e8f0"}; border-left: 1px solid ${isDark ? "rgba(255,255,255,0.06)" : "#e2e8f0"};
} }
.app-scrollbar::-webkit-scrollbar-thumb { .app-scrollbar::-webkit-scrollbar-thumb {
background: ${isDark ? "#334155" : "#94a3b8"}; background: ${isDark ? "#334155" : "#94a3b8"};
border-radius: 999px; border-radius: 999px;
border: 2px solid ${isDark ? "#0b1220" : "#f1f5f9"}; border: 2px solid ${isDark ? "#07101B" : "#f8fafc"};
} }
.app-scrollbar::-webkit-scrollbar-thumb:hover { .app-scrollbar::-webkit-scrollbar-thumb:hover {
@@ -115,17 +116,25 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
<main <main
className={ className={
isDark isDark
? `app-scrollbar min-h-0 flex-1 border-t border-white/10 bg-[#0b1220] ${activePage === "dashboard" ? `app-scrollbar min-h-0 flex-1 border-t border-white/10 ${isDashboard
? "overflow-hidden p-0" ? "overflow-y-auto bg-[#07101B] p-0"
: "overflow-y-auto p-4" : "overflow-y-auto bg-[#0b1220] p-4"
}` }`
: `app-scrollbar min-h-0 flex-1 border-t border-slate-200 bg-slate-100 ${activePage === "dashboard" : `app-scrollbar min-h-0 flex-1 border-t border-slate-200 ${isDashboard
? "overflow-hidden p-0" ? "overflow-y-auto bg-white p-0"
: "overflow-y-auto p-4" : "overflow-y-auto bg-slate-100 p-4"
}` }`
} }
> >
<div className="h-full w-full"> <div
className={
isDashboard
? isDark
? "h-full w-full bg-[#07101B]"
: "h-full w-full bg-white"
: "h-full w-full"
}
>
{children({ {children({
theme, theme,
snapshots: telemetry.snapshots, snapshots: telemetry.snapshots,
-47
View File
@@ -1,47 +0,0 @@
type BottomStatusBarProps = {
theme: "dark" | "light";
backendPort?: string;
mode?: string;
controllerName?: string;
controllerIp?: string;
};
export function BottomStatusBar({
theme,
backendPort = "18450",
mode = "Local",
controllerName = "PLC_Principal",
controllerIp = "198.19.0.176",
}: BottomStatusBarProps) {
const isDark = theme === "dark";
return (
<footer
className={
isDark
? "flex h-10 items-center justify-between bg-[#0E1A24] px-6 text-sm text-[#D8E2EC]"
: "flex h-10 items-center justify-between bg-[#F4F7FA] px-6 text-sm text-[#445569]"
}
>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span>Porto Backend: {backendPort}</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span>Modo: {mode}</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span>Controlador: {controllerName}</span>
</div>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span>IP Controlador: {controllerIp}</span>
</div>
</footer>
);
}
+11 -7
View File
@@ -120,15 +120,16 @@ export function Sidebar({
<aside <aside
className={ className={
isDark isDark
? `${collapsed ? "w-20" : "w-[290px]"} flex h-full flex-col border-r border-[#263247] bg-[#0B1220] px-4 py-5 text-slate-100 transition-all duration-200` ? `${collapsed ? "w-20" : "w-[245px]"} flex h-full min-h-0 flex-col border-r border-[#263247] bg-[#0B1220] text-slate-100 transition-all duration-200`
: `${collapsed ? "w-20" : "w-[290px]"} flex h-full flex-col border-r border-[#D7DEE8] bg-[#F3F6FA] px-4 py-5 text-[#0F172A] transition-all duration-200` : `${collapsed ? "w-20" : "w-[245px]"} flex h-full min-h-0 flex-col border-r border-[#D7DEE8] bg-[#F3F6FA] text-[#0F172A] transition-all duration-200`
} }
> >
<div className="shrink-0 px-4 pt-5">
<div <div
className={ className={
collapsed collapsed
? "mb-10 flex items-center justify-center" ? "mb-6 flex items-center justify-center"
: "mb-10 flex items-center gap-3 px-1" : "mb-6 flex items-center gap-3 px-1"
} }
> >
<div <div
@@ -163,8 +164,9 @@ export function Sidebar({
</div> </div>
)} )}
</div> </div>
</div>
<nav className="space-y-1.5"> <nav className="app-scrollbar min-h-0 flex-1 space-y-1.5 overflow-y-auto overflow-x-hidden px-4 pb-4">
<NavItem <NavItem
theme={theme} theme={theme}
collapsed={collapsed} collapsed={collapsed}
@@ -276,13 +278,14 @@ export function Sidebar({
})} })}
</nav> </nav>
<div className="shrink-0 px-4 pb-5 pt-3">
<button <button
type="button" type="button"
onClick={onToggleCollapsed} onClick={onToggleCollapsed}
className={ className={
isDark isDark
? `mt-auto flex items-center justify-center gap-2 ${RADIUS} border border-[#263247] bg-[#111A2B] px-4 py-3 text-[14px] font-semibold text-[#9BAAC1] transition hover:border-[#33445F] hover:bg-[#162033] hover:text-white` ? `flex w-full items-center justify-center gap-2 ${RADIUS} border border-[#263247] bg-[#111A2B] px-4 py-3 text-[14px] font-semibold text-[#9BAAC1] transition hover:border-[#33445F] hover:bg-[#162033] hover:text-white`
: `mt-auto flex items-center justify-center gap-2 ${RADIUS} border border-[#CBD5E1] bg-white px-4 py-3 text-[14px] font-semibold text-slate-600 transition hover:bg-[#EAF0F7] hover:text-[#0F172A]` : `flex w-full items-center justify-center gap-2 ${RADIUS} border border-[#CBD5E1] bg-white px-4 py-3 text-[14px] font-semibold text-slate-600 transition hover:bg-[#EAF0F7] hover:text-[#0F172A]`
} }
> >
{collapsed ? ( {collapsed ? (
@@ -294,6 +297,7 @@ export function Sidebar({
</> </>
)} )}
</button> </button>
</div>
</aside> </aside>
); );
} }
@@ -4,8 +4,8 @@ export async function openChartWindow(
chartId: string, chartId: string,
theme: "dark" | "light", theme: "dark" | "light",
title: string, title: string,
scope: "GLOBAL" | "CLIMATE" = "GLOBAL", scope: "GLOBAL" | "CLIMATE" | "METEO" = "GLOBAL",
channel: "maincharts" | "climatecharts" = "maincharts", channel: "maincharts" | "climatecharts" | "meteocharts" = "maincharts",
) { ) {
const label = `${channel}-${chartId}`; const label = `${channel}-${chartId}`;
@@ -76,7 +76,7 @@ export function useClimateChartSeries(
value: point.numericValue as number, value: point.numericValue as number,
})); }));
return [key, aggregatePoints(points, interval)] as const; return [key, points] as const;
}), }),
); );
@@ -1,60 +0,0 @@
import { Droplets, Lightbulb, Power } from "lucide-react";
import type { DashboardOverview } from "../types/DashboardOverview";
type Props = {
irrigation?: DashboardOverview["irrigation"];
lighting?: DashboardOverview["lighting"];
};
export function DashboardOperationsSection({ irrigation, lighting }: Props) {
return (
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<OperationCard
title="Rega"
value={`${irrigation?.activeValveCount ?? 0}`}
subtitle={`válvulas ativas / ${irrigation?.controllerCount ?? 0} controladores`}
icon={Droplets}
/>
<OperationCard
title="Bombas"
value={`${irrigation?.activePumpCount ?? 0}`}
subtitle="bombas ativas"
icon={Power}
/>
<OperationCard
title="Iluminação"
value={`${lighting?.activeSectorCount ?? 0}`}
subtitle={`setores ativos / ${lighting?.sectorCount ?? 0} setores`}
icon={Lightbulb}
/>
</section>
);
}
type OperationCardProps = {
title: string;
value: string;
subtitle: string;
icon: React.ElementType;
};
function OperationCard({ title, value, subtitle, icon: Icon }: OperationCardProps) {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-slate-500 dark:text-slate-400">{title}</p>
<Icon className="h-5 w-5 text-slate-400" />
</div>
<p className="text-3xl font-semibold text-slate-900 dark:text-slate-50">
{value}
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{subtitle}
</p>
</div>
);
}
@@ -1,180 +0,0 @@
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { HistorianDashboardResponse } from "../../../types/historian";
type ChartSeries = {
key: string;
label: string;
color: string;
};
type DashboardTrendChartProps = {
title: string;
subtitle: string;
data: HistorianDashboardResponse | null;
series: ChartSeries[];
theme: "dark" | "light";
};
export function DashboardTrendChart({
title,
subtitle,
data,
series,
theme,
}: DashboardTrendChartProps) {
const isDark = theme === "dark";
const chartData = buildChartData(data, series);
return (
<div
className={
isDark
? "rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
: "rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
}
>
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h2 className={isDark ? "text-base font-semibold text-white" : "text-base font-semibold text-slate-950"}>
{title}
</h2>
<p className={isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"}>
{subtitle}
</p>
</div>
<div className="flex flex-wrap justify-end gap-2">
{series.map((item) => (
<div key={item.key} className="flex items-center gap-1.5 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className={isDark ? "text-slate-300" : "text-slate-600"}>
{item.label}
</span>
</div>
))}
</div>
</div>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<defs>
{series.map((item) => (
<linearGradient key={item.key} id={gradientId(item.label)} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={item.color} stopOpacity={0.35} />
<stop offset="95%" stopColor={item.color} stopOpacity={0.02} />
</linearGradient>
))}
</defs>
<CartesianGrid
strokeDasharray="4 4"
vertical={false}
stroke={isDark ? "rgba(148,163,184,0.16)" : "rgba(100,116,139,0.18)"}
/>
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
stroke={isDark ? "#94a3b8" : "#64748b"}
fontSize={12}
/>
<YAxis
tickLine={false}
axisLine={false}
stroke={isDark ? "#94a3b8" : "#64748b"}
fontSize={12}
/>
<Tooltip
cursor={{
stroke: isDark ? "#64748b" : "#94a3b8",
strokeWidth: 1,
}}
contentStyle={{
background: isDark ? "#0B1220" : "#ffffff",
border: isDark ? "1px solid rgba(255,255,255,0.12)" : "1px solid #e2e8f0",
borderRadius: "14px",
color: isDark ? "#f8fafc" : "#0f172a",
boxShadow: "0 18px 45px rgba(0,0,0,0.25)",
}}
labelStyle={{
color: isDark ? "#cbd5e1" : "#475569",
fontWeight: 600,
}}
/>
{series.map((item) => (
<Area
key={item.key}
type="monotone"
dataKey={item.label}
stroke={item.color}
fill={`url(#${gradientId(item.label)})`}
strokeWidth={2.5}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}
function buildChartData(
data: HistorianDashboardResponse | null,
series: ChartSeries[],
) {
if (!data) return [];
const pointsByTimestamp = new Map<string, Record<string, string | number>>();
for (const item of series) {
const points = data.series[item.key] ?? [];
for (const point of points) {
const timestamp = point.timestamp;
const existing = pointsByTimestamp.get(timestamp) ?? {
timestamp,
time: formatTime(timestamp),
};
existing[item.label] = point.numericValue ?? 0;
pointsByTimestamp.set(timestamp, existing);
}
}
return Array.from(pointsByTimestamp.values()).sort((a, b) =>
String(a.timestamp).localeCompare(String(b.timestamp)),
);
}
function formatTime(timestamp: string) {
return new Date(timestamp).toLocaleTimeString("pt-PT", {
hour: "2-digit",
minute: "2-digit",
});
}
function gradientId(label: string) {
return `gradient-${label
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/\./g, "")}`;
}
@@ -1,31 +0,0 @@
type StatusPillProps = {
active: boolean | null | undefined;
activeLabel?: string;
inactiveLabel?: string;
};
export function StatusPill({
active,
activeLabel = "Ativo",
inactiveLabel = "Inativo",
}: StatusPillProps) {
if (active === null || active === undefined) {
return (
<span className="rounded-full bg-slate-200 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
--
</span>
);
}
return (
<span
className={
active
? "rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
: "rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
}
>
{active ? activeLabel : inactiveLabel}
</span>
);
}
+64 -263
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { import {
ArrowRight, ArrowRight,
BarChart3, BarChart3,
@@ -22,35 +23,15 @@ type DashboardPageProps = {
const RADIUS = "rounded-[6px]"; const RADIUS = "rounded-[6px]";
export function DashboardPage({ export function DashboardPage({ theme, onOpenMeteo, onNavigate }: DashboardPageProps) {
theme,
onOpenMeteo,
onNavigate,
}: DashboardPageProps) {
const isDark = theme === "dark"; const isDark = theme === "dark";
const weather = useWeatherForecast(); const weather = useWeatherForecast();
return ( return (
<div <div className={isDark ? "relative h-full text-slate-100" : "relative h-full text-[#0F172A]"}>
className={ <img src={backgroundImage} alt="" className="absolute inset-0 h-full w-full object-cover" />
isDark
? "relative h-full overflow-hidden text-slate-100"
: "relative h-full overflow-hidden text-[#0F172A]"
}
>
<img
src={backgroundImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
<div <div className={isDark ? "absolute inset-0 bg-[#07101B]/62" : "absolute inset-0 bg-white/20"} />
className={
isDark
? "absolute inset-0 bg-[#07101B]/62"
: "absolute inset-0 bg-white/20"
}
/>
<div <div
className={ className={
@@ -60,63 +41,45 @@ export function DashboardPage({
} }
/> />
<main className="relative z-10 grid h-full w-full grid-rows-[minmax(330px,1.25fr)_118px_minmax(310px,1fr)_22px] gap-5 px-14 pb-3 pt-10"> <style>{`
<section className="relative min-h-0 pb-10"> .dashboard-weather-card > * {
<div className="max-w-[620px] pt-5"> position: relative !important;
<h1 inset: auto !important;
className={ right: auto !important;
isDark top: auto !important;
? "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-white" transform: none !important;
: "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-[#0F172A]" width: 100% !important;
max-width: 360px !important;
} }
> `}</style>
<main className="relative z-10 grid h-full w-full grid-rows-[auto_auto_minmax(260px,1fr)_24px] gap-4 px-5 pb-3 pt-8 xl:px-7 2xl:px-9">
<section className="grid grid-cols-[minmax(0,1fr)_340px] items-start gap-6 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_380px]">
<div className="min-w-0 max-w-[640px]">
<h1 className={isDark ? "text-[42px] font-black leading-[1.02] tracking-[-0.06em] text-white xl:text-[48px] 2xl:text-[54px]" : "text-[42px] font-black leading-[1.02] tracking-[-0.06em] text-[#0F172A] xl:text-[48px] 2xl:text-[54px]"}>
Bem-vindo ao Bem-vindo ao
<br /> <br />
<span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}> <span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>Litoral Central</span>
Litoral Central
</span>
</h1> </h1>
<div <div className={isDark ? "mt-5 h-[2px] w-14 bg-[#4FD1C5]" : "mt-5 h-[2px] w-14 bg-[#0F766E]"} />
className={
isDark
? "mt-7 h-[2px] w-14 bg-[#4FD1C5]"
: "mt-7 h-[2px] w-14 bg-[#0F766E]"
}
/>
<p <p className={isDark ? "mt-5 max-w-[560px] text-[14px] leading-6 text-[#A8B3C7] xl:text-[15px] xl:leading-7" : "mt-5 max-w-[560px] text-[14px] leading-6 text-slate-700 xl:text-[15px] xl:leading-7"}>
className={
isDark
? "mt-7 max-w-[500px] text-[15px] leading-7 text-[#A8B3C7]"
: "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-700"
}
>
A sua plataforma inteligente para gestão de operações agrícolas. A sua plataforma inteligente para gestão de operações agrícolas.
Acompanhe as condições, controle os sistemas e maximize a Acompanhe as condições, controle os sistemas e maximize a eficiência no campo.
eficiência no campo.
</p> </p>
<button <button
type="button" type="button"
onClick={onOpenMeteo} onClick={onOpenMeteo}
className={ className={isDark ? `mt-6 inline-flex h-[48px] items-center gap-4 ${RADIUS} border border-[#2A3950] bg-[#111A2B] px-6 text-sm font-extrabold text-white transition hover:border-[#36506D] hover:bg-[#162235]` : `mt-6 inline-flex h-[48px] items-center gap-4 ${RADIUS} border border-[#CBD5E1] bg-white px-6 text-sm font-extrabold text-[#0F172A] transition hover:bg-[#F8FAFC]`}
isDark
? `mt-8 inline-flex h-[52px] items-center gap-5 ${RADIUS} border border-[#2A3950] bg-[#111A2B] px-7 text-sm font-extrabold text-white transition hover:border-[#36506D] hover:bg-[#162235]`
: `mt-8 inline-flex h-[52px] items-center gap-5 ${RADIUS} border border-[#CBD5E1] bg-white px-7 text-sm font-extrabold text-[#0F172A] transition hover:bg-[#F8FAFC]`
}
> >
Explorar plataforma Explorar plataforma
<ArrowRight <ArrowRight className={isDark ? "h-5 w-5 text-[#4FD1C5]" : "h-5 w-5 text-[#0F766E]"} />
className={
isDark
? "h-5 w-5 text-[#4FD1C5]"
: "h-5 w-5 text-[#0F766E]"
}
/>
</button> </button>
</div> </div>
<div className="dashboard-weather-card flex justify-end pt-3">
<WeatherForecastCard <WeatherForecastCard
compact compact
theme={theme} theme={theme}
@@ -125,119 +88,34 @@ export function DashboardPage({
error={weather.error} error={weather.error}
onOpenMeteo={onOpenMeteo} onOpenMeteo={onOpenMeteo}
/> />
</div>
</section> </section>
<section className="grid min-h-0 grid-cols-3 gap-5"> <section className="grid grid-cols-3 gap-3 xl:gap-4">
<InfoCard <InfoCard theme={theme} icon={<CloudSun className="h-6 w-6 xl:h-7 xl:w-7" />} 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")} />
theme={theme} <InfoCard theme={theme} icon={<TabletSmartphone className="h-6 w-6 xl:h-7 xl:w-7" />} 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")} />
icon={<CloudSun className="h-7 w-7" />} <InfoCard theme={theme} icon={<BarChart3 className="h-6 w-6 xl:h-7 xl:w-7" />} 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")}
/>
<InfoCard
theme={theme}
icon={<TabletSmartphone className="h-7 w-7" />}
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")}
/>
<InfoCard
theme={theme}
icon={<BarChart3 className="h-7 w-7" />}
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")}
/>
</section> </section>
<section <section className={isDark ? `${RADIUS} min-h-0 border border-[#263247] bg-[#0E1726]/92 p-5 shadow-[0_18px_50px_rgba(0,0,0,0.28)] backdrop-blur-md xl:p-6` : `${RADIUS} min-h-0 border border-[#D7DEE8] bg-white p-5 shadow-[0_14px_40px_rgba(15,23,42,0.06)] backdrop-blur-md xl:p-6`}>
className={ <div className="grid h-full min-h-0 grid-cols-[minmax(0,0.95fr)_minmax(420px,1fr)] items-center gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(520px,1fr)]">
isDark <div className="min-w-0">
? `min-h-0 overflow-visible ${RADIUS} border border-[#263247] bg-[#0E1726]/92 p-8 shadow-[0_18px_50px_rgba(0,0,0,0.28)] backdrop-blur-md` <p className={isDark ? "text-[11px] font-bold uppercase tracking-[0.24em] text-[#7F8CA3] xl:text-xs" : "text-[11px] font-bold uppercase tracking-[0.24em] text-[#0F766E] xl:text-xs"}>A Nossa Missão</p>
: `min-h-0 overflow-visible ${RADIUS} border border-[#D7DEE8] bg-white p-8 shadow-[0_14px_40px_rgba(15,23,42,0.06)] backdrop-blur-md`
}
>
<div className="grid h-full grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)] items-center gap-2">
<div>
<p
className={
isDark
? "text-xs font-bold uppercase tracking-[0.28em] text-[#7F8CA3]"
: "text-xs font-bold uppercase tracking-[0.28em] text-[#0F766E]"
}
>
A Nossa Missão
</p>
<h2 <h2 className={isDark ? "mt-3 text-[25px] font-black leading-tight tracking-[-0.045em] text-white xl:text-[29px]" : "mt-3 text-[25px] font-black leading-tight tracking-[-0.045em] text-[#0F172A] xl:text-[29px]"}>
className={
isDark
? "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-white"
: "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-[#0F172A]"
}
>
Soluções inovadoras para Soluções inovadoras para
<br /> <br />
uma{" "} uma <span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>agricultura sustentável</span>
<span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>
agricultura sustentável
</span>
</h2> </h2>
<p <p className={isDark ? "mt-3 max-w-[560px] text-[13px] leading-5 text-[#7F8CA3] xl:text-sm xl:leading-6" : "mt-3 max-w-[560px] text-[13px] leading-5 text-slate-600 xl:text-sm xl:leading-6"}>
className={ Tecnologia, conhecimento e proximidade para impulsionar o futuro do setor agrícola em Portugal.
isDark
? "mt-4 max-w-[560px] text-sm leading-6 text-[#7F8CA3]"
: "mt-4 max-w-[560px] text-sm leading-6 text-slate-600"
}
>
Tecnologia, conhecimento e proximidade para impulsionar o futuro
do setor agrícola em Portugal.
</p> </p>
<div <div className={isDark ? "mt-4 grid grid-cols-3 gap-3 border-t border-[#263247] pt-4" : "mt-4 grid grid-cols-3 gap-3 border-t border-[#D7DEE8] pt-4"}>
className={ <MissionItem theme={theme} icon={<Sprout />} title="Sustentabilidade" text="Compromisso com o futuro" />
isDark <MissionItem theme={theme} icon={<ShieldCheck />} title="Confiabilidade" text="Tecnologia robusta e segura" />
? "mt-6 grid grid-cols-3 gap-5 border-t border-[#263247] pt-6" <MissionItem theme={theme} icon={<Users />} title="Apoio próximo" text="Sempre ao seu lado" />
: "mt-6 grid grid-cols-3 gap-5 border-t border-[#D7DEE8] pt-6"
}
>
<MissionItem
theme={theme}
icon={<Sprout />}
title="Sustentabilidade"
text="Compromisso com o futuro"
/>
<MissionItem
theme={theme}
icon={<ShieldCheck />}
title="Confiabilidade"
text="Tecnologia robusta e segura"
/>
<MissionItem
theme={theme}
icon={<Users />}
title="Apoio próximo"
text="Sempre ao seu lado"
/>
</div> </div>
</div> </div>
@@ -245,126 +123,55 @@ export function DashboardPage({
</div> </div>
</section> </section>
<footer <footer className={isDark ? "flex min-h-0 items-center justify-between px-1 text-xs text-[#7F8CA3]" : "flex min-h-0 items-center justify-between px-1 text-xs text-slate-500"}>
className={
isDark
? "flex min-h-0 items-center justify-between px-56 text-xs text-[#7F8CA3]"
: "flex min-h-0 items-center justify-between px-56 text-xs text-slate-500"
}
>
<span>© 2026 Litoral Central. Todos os direitos reservados.</span> <span>© 2026 Litoral Central. Todos os direitos reservados.</span>
<span> <span>Feito em Portugal <span className="ml-2">🇵🇹</span></span>
Feito em Portugal <span className="ml-2">🇵🇹</span>
</span>
</footer> </footer>
</main> </main>
</div> </div>
); );
} }
function InfoCard({ function InfoCard({ theme, icon, iconClass, title, text, onClick }: { theme: "dark" | "light"; icon: ReactNode; iconClass: string; title: string; text: string; onClick?: () => void }) {
theme,
icon,
iconClass,
title,
text,
onClick,
}: {
theme: "dark" | "light";
icon: React.ReactNode;
iconClass: string;
title: string;
text: string;
onClick?: () => void;
}) {
const isDark = theme === "dark"; const isDark = theme === "dark";
return ( return (
<button <button type="button" onClick={onClick} className={isDark ? `${RADIUS} flex min-h-[106px] w-full items-center justify-between border border-[#263247] bg-[#0E1726]/92 p-4 text-left shadow-[0_18px_50px_rgba(0,0,0,0.24)] backdrop-blur-md transition hover:border-[#36506D] hover:bg-[#132033] xl:min-h-[114px] xl:p-5` : `${RADIUS} flex min-h-[106px] w-full items-center justify-between border border-[#D7DEE8] bg-white p-4 text-left shadow-[0_8px_24px_rgba(15,23,42,0.05)] backdrop-blur-md transition hover:bg-[#F8FAFC] xl:min-h-[114px] xl:p-5`}>
type="button" <div className="flex min-w-0 items-center gap-3 xl:gap-4">
onClick={onClick} <div className={`grid h-[50px] w-[50px] shrink-0 place-items-center ${RADIUS} ${iconClass} xl:h-[58px] xl:w-[58px]`}>
className={
isDark
? `${RADIUS} flex h-full w-full items-center justify-between border border-[#263247] bg-[#0E1726]/92 p-5 text-left shadow-[0_18px_50px_rgba(0,0,0,0.24)] backdrop-blur-md transition hover:border-[#36506D] hover:bg-[#132033]`
: `${RADIUS} flex h-full w-full items-center justify-between border border-[#D7DEE8] bg-white p-5 text-left shadow-[0_8px_24px_rgba(15,23,42,0.05)] backdrop-blur-md transition hover:bg-[#F8FAFC]`
}
>
<div className="flex items-center gap-5">
<div
className={`grid h-[62px] w-[62px] place-items-center ${RADIUS} ${iconClass}`}
>
{icon} {icon}
</div> </div>
<div> <div className="min-w-0">
<h3 <h3 className={isDark ? "truncate text-sm font-black text-white xl:text-base" : "truncate text-sm font-black text-[#0F172A] xl:text-base"}>
className={
isDark
? "text-base font-black text-white"
: "text-base font-black text-[#0F172A]"
}
>
{title} {title}
</h3> </h3>
<p <p className={isDark ? "mt-1 line-clamp-2 max-w-[300px] text-xs leading-4 text-[#7F8CA3] xl:text-sm xl:leading-5" : "mt-1 line-clamp-2 max-w-[300px] text-xs leading-4 text-slate-600 xl:text-sm xl:leading-5"}>
className={
isDark
? "mt-2 max-w-[280px] text-sm leading-5 text-[#7F8CA3]"
: "mt-2 max-w-[280px] text-sm leading-5 text-slate-600"
}
>
{text} {text}
</p> </p>
</div> </div>
</div> </div>
<ArrowRight <ArrowRight className={isDark ? "ml-3 h-4 w-4 shrink-0 text-[#4FD1C5] xl:h-5 xl:w-5" : "ml-3 h-4 w-4 shrink-0 text-[#0F766E] xl:h-5 xl:w-5"} />
className={
isDark
? "h-5 w-5 shrink-0 text-[#4FD1C5]"
: "h-5 w-5 shrink-0 text-[#0F766E]"
}
/>
</button> </button>
); );
} }
function MissionItem({ function MissionItem({ theme, icon, title, text }: { theme: "dark" | "light"; icon: ReactNode; title: string; text: string }) {
theme,
icon,
title,
text,
}: {
theme: "dark" | "light";
icon: React.ReactNode;
title: string;
text: string;
}) {
const isDark = theme === "dark"; const isDark = theme === "dark";
return ( return (
<div className="flex items-center gap-3"> <div className="flex min-w-0 items-center gap-2 xl:gap-3">
<div className={isDark ? "h-7 w-7 text-[#4FD1C5]" : "h-7 w-7 text-[#0F766E]"}> <div className={isDark ? "h-5 w-5 shrink-0 text-[#4FD1C5] xl:h-6 xl:w-6" : "h-5 w-5 shrink-0 text-[#0F766E] xl:h-6 xl:w-6"}>
{icon} {icon}
</div> </div>
<div>
<h4 <div className="min-w-0">
className={ <h4 className={isDark ? "truncate text-xs font-black text-white xl:text-sm" : "truncate text-xs font-black text-[#0F172A] xl:text-sm"}>
isDark
? "text-sm font-black text-white"
: "text-sm font-black text-[#0F172A]"
}
>
{title} {title}
</h4> </h4>
<p <p className={isDark ? "mt-1 truncate text-[11px] text-[#7F8CA3] xl:text-xs" : "mt-1 truncate text-[11px] text-slate-600 xl:text-xs"}>
className={
isDark
? "mt-1 text-xs text-[#7F8CA3]"
: "mt-1 text-xs text-slate-600"
}
>
{text} {text}
</p> </p>
</div> </div>
@@ -373,18 +180,12 @@ function MissionItem({
} }
function FarmIllustration({ theme }: { theme: "dark" | "light" }) { function FarmIllustration({ theme }: { theme: "dark" | "light" }) {
const isDark = theme === "dark";
return ( return (
<div className="relative h-full min-h-[270px] w-full overflow-visible"> <div className="relative h-full min-h-[210px] w-full overflow-hidden">
<img <img
src={isDark ? farmdrawImage : farmdrawWhiteImage} src={theme === "dark" ? farmdrawImage : farmdrawWhiteImage}
alt="" alt=""
className={ className="absolute right-[-10px] top-1/2 h-[280px] max-w-none -translate-y-1/2 opacity-95 xl:h-[330px] 2xl:h-[360px]"
isDark
? "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-90"
: "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-100"
}
/> />
</div> </div>
); );
@@ -536,7 +536,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
}; };
}, [layoutMode]); }, [layoutMode]);
return ( return (
<div className="space-y-4 pb-6"> <div className="flex h-[calc(100vh-88px)] min-h-0 flex-col gap-3 overflow-hidden pb-2">
<div <div
className={ className={
isDark isDark
@@ -860,7 +860,7 @@ function WorkspaceChartContainer({
? "relative rounded-[7px] ring-2 ring-[#4FD1C5]/50 transition" ? "relative rounded-[7px] ring-2 ring-[#4FD1C5]/50 transition"
: isMoving : isMoving
? "relative rounded-[7px] opacity-60 ring-2 ring-[#4FD1C5]" ? "relative rounded-[7px] opacity-60 ring-2 ring-[#4FD1C5]"
: "relative rounded-[7px]" : "relative min-h-0 overflow-hidden rounded-[7px]"
} }
> >
<div className="absolute right-4 top-4 z-20 flex items-center gap-1.5"> <div className="absolute right-4 top-4 z-20 flex items-center gap-1.5">
@@ -1461,17 +1461,20 @@ function EmptyVariableList({ theme }: { theme: "dark" | "light" }) {
} }
function layoutGridClass(layoutMode: ChartLayoutMode) { function layoutGridClass(layoutMode: ChartLayoutMode) {
if (layoutMode === "twoColumns") {
return "grid gap-4 2xl:grid-cols-2";
}
if (layoutMode === "fourGrid") { 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) { function getVisibleSlotCount(layoutMode: ChartLayoutMode) {
if (layoutMode === "single") return 1; if (layoutMode === "single") return 1;
if (layoutMode === "twoColumns") return 2; if (layoutMode === "twoColumns") return 2;
@@ -21,53 +21,68 @@ export function useAccumulatedHistory(
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!sensor) { if (!sensor || !sensor.key) {
console.warn("[AccumulatedHistory SKIPPED] sensor is null or missing key", {
sensor,
range,
});
setBuckets([]); setBuckets([]);
setLoading(false);
return; return;
} }
const sensorKey = sensor.key;
const controller = new AbortController(); const controller = new AbortController();
const sensorKey = sensor.key
async function loadAccumulated() { async function loadAccumulated() {
try {
setLoading(true); setLoading(true);
try {
const params = new URLSearchParams({ const params = new URLSearchParams({
key: sensorKey, key: sensorKey,
range, range,
}); });
const url = `${BACKEND_URL}/api/historian/accumulated?${params.toString()}`; const url = `${BACKEND_URL}/api/historian/accumulated?${params.toString()}`;
console.log("I AM HEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEERE");
console.log("[AccumulatedHistory URL]", url);
const response = await fetch( const response = await fetch(url, {
`${BACKEND_URL}/api/historian/accumulated?${params.toString()}`, method: "GET",
{ signal: controller.signal }, signal: controller.signal,
); cache: "no-store",
headers: {
if (!response.ok) { Accept: "application/json",
throw new Error("Failed to load accumulated history"); "Cache-Control": "no-cache",
} Pragma: "no-cache",
},
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,
}); });
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) { } catch (error) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
console.error("Failed to load accumulated history", error); console.error("[AccumulatedHistory ERROR]", error);
setBuckets([]); setBuckets([]);
} finally { } finally {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
@@ -78,13 +93,13 @@ export function useAccumulatedHistory(
loadAccumulated(); loadAccumulated();
return () => controller.abort(); return () => {
}, [sensor?.key, range]); controller.abort();
};
}, [sensor?.key, sensor?.name, range]);
return { return {
buckets, buckets,
loading, loading,
}; };
} }
export type { AccumulatedRange };
@@ -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<ChartVariable[]>(
() =>
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,
};
}
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -47,6 +47,7 @@ import { useAccumulatedHistory } from "../hooks/useAccumulatedHistory";
import type { AccumulatedBucket } from "../hooks/useAccumulatedHistory"; import type { AccumulatedBucket } from "../hooks/useAccumulatedHistory";
type MeteoPageProps = { type MeteoPageProps = {
theme: "dark" | "light"; theme: "dark" | "light";
onOpenMeteoCharts: () => void;
}; };
type ChartPoint = { type ChartPoint = {
@@ -78,7 +79,7 @@ const HISTORY_KEYS = {
type HistoryKey = (typeof HISTORY_KEYS)[keyof typeof 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 { sensors } = useMeteoModuleStream();
const weatherForecast = useWeatherForecast(); const weatherForecast = useWeatherForecast();
const [weatherBoardOpen, setWeatherBoardOpen] = useState(false); const [weatherBoardOpen, setWeatherBoardOpen] = useState(false);
@@ -246,9 +247,11 @@ export function MeteoPage({ theme }: MeteoPageProps) {
<RealtimeChartPanel <RealtimeChartPanel
theme={theme} theme={theme}
onOpenMeteoCharts={onOpenMeteoCharts}
series={chartSeries} series={chartSeries}
historyLoading={historyLoading} historyLoading={historyLoading}
hours={HISTORY_HOURS} hours={HISTORY_HOURS}
/> />
</div> </div>
@@ -474,7 +477,7 @@ function formatAccumulatedValue(value: number | null, unit?: string) {
if (unit === "Wh/m²") { if (unit === "Wh/m²") {
return { return {
value: (value / 1000).toFixed(2), value: value.toFixed(1),
unit: "kWh/m²", unit: "kWh/m²",
}; };
} }
@@ -1153,15 +1156,18 @@ function windConsistency(
function RealtimeChartPanel({ function RealtimeChartPanel({
theme, theme,
onOpenMeteoCharts,
series, series,
historyLoading, historyLoading,
}: { }: {
theme: "dark" | "light"; theme: "dark" | "light";
onOpenMeteoCharts: () => void;
series: ChartSeries[]; series: ChartSeries[];
historyLoading: boolean; historyLoading: boolean;
hours: number; hours: number;
}) { }) {
const [mode, setMode] = useState<WorkspaceChartMode>("line"); const [mode, setMode] = useState<WorkspaceChartMode>("line");
const [timeRange, setTimeRange] = useState<WorkspaceChartTimeRange>("6h"); const [timeRange, setTimeRange] = useState<WorkspaceChartTimeRange>("6h");
const [interval, setInterval] = useState<WorkspaceChartInterval>("5m"); const [interval, setInterval] = useState<WorkspaceChartInterval>("5m");
const [visibleKeys, setVisibleKeys] = useState<HistoryKey[]>([ const [visibleKeys, setVisibleKeys] = useState<HistoryKey[]>([
@@ -1212,6 +1218,7 @@ function RealtimeChartPanel({
<button <button
type="button" type="button"
className="rounded-[5px] border border-sky-400/20 bg-sky-400/10 px-3 py-2 text-xs font-black text-sky-200 transition hover:bg-sky-400/15" className="rounded-[5px] border border-sky-400/20 bg-sky-400/10 px-3 py-2 text-xs font-black text-sky-200 transition hover:bg-sky-400/15"
onClick={onOpenMeteoCharts}
> >
Gráficos Personalizados Gráficos Personalizados
</button> </button>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { import type {
WorkspaceChartInterval, WorkspaceChartInterval,
WorkspaceChartPoint, WorkspaceChartPoint,
@@ -20,8 +20,8 @@ export function useTelemetryChartSeries(
interval: WorkspaceChartInterval, interval: WorkspaceChartInterval,
) { ) {
const [seriesByKey, setSeriesByKey] = useState<Record<string, WorkspaceChartPoint[]>>({}); const [seriesByKey, setSeriesByKey] = useState<Record<string, WorkspaceChartPoint[]>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const [initialized, setInitialized] = useState(false); const initializedRef = useRef(false);
const keySignature = useMemo( const keySignature = useMemo(
() => sensorKeys.slice().sort().join(","), () => sensorKeys.slice().sort().join(","),
@@ -31,20 +31,32 @@ export function useTelemetryChartSeries(
useEffect(() => { useEffect(() => {
if (sensorKeys.length === 0) { if (sensorKeys.length === 0) {
setSeriesByKey({}); setSeriesByKey({});
setLoading(false);
initializedRef.current = false;
return; return;
} }
const controller = new AbortController(); const controller = new AbortController();
async function loadHistory() { async function loadHistory(showLoading: boolean) {
const startedAt = performance.now();
try { try {
const to = new Date(); const to = new Date();
const from = new Date(to.getTime() - rangeToMs(timeRange)); const from = new Date(to.getTime() - rangeToMs(timeRange));
if (!initialized) { if (showLoading && !initializedRef.current) {
setLoading(true); setLoading(true);
} }
console.log("[TelemetryChartSeries REQUEST]", {
sensorKeys,
timeRange,
interval,
from: from.toISOString(),
to: to.toISOString(),
});
const entries = await Promise.all( const entries = await Promise.all(
sensorKeys.map(async (key) => { sensorKeys.map(async (key) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -53,13 +65,15 @@ export function useTelemetryChartSeries(
to: to.toISOString(), to: to.toISOString(),
}); });
const response = await fetch( const url = `${BACKEND_URL}/api/historian/series?${params.toString()}`;
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
{ signal: controller.signal }, const response = await fetch(url, {
); signal: controller.signal,
cache: "no-store",
});
if (!response.ok) { 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[]; const payload = (await response.json()) as HistorianPoint[];
@@ -74,17 +88,24 @@ export function useTelemetryChartSeries(
timestamp: point.timestamp, timestamp: point.timestamp,
value: point.numericValue as number, value: point.numericValue as number,
})); }));
return [key, aggregatePoints(points, interval)] as const; return [key, aggregatePoints(points, interval)] as const;
}), }),
); );
setSeriesByKey(Object.fromEntries(entries)); setSeriesByKey(Object.fromEntries(entries));
setInitialized(true); initializedRef.current = true;
console.log("[TelemetryChartSeries DONE]", {
sensorCount: sensorKeys.length,
durationMs: Math.round(performance.now() - startedAt),
});
} catch (error) { } catch (error) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
console.error("Failed to load telemetry chart history", error); console.error("[TelemetryChartSeries ERROR]", error);
if (!initialized) {
if (!initializedRef.current) {
setSeriesByKey({}); setSeriesByKey({});
} }
} finally { } 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(() => { const intervalId = window.setInterval(() => {
loadHistory(); loadHistory(false);
}, 10000); }, refreshMs);
return () => { return () => {
controller.abort(); controller.abort();
window.clearInterval(intervalId); window.clearInterval(intervalId);
}; };
}, [keySignature, timeRange, interval, initialized]); }, [keySignature, timeRange, interval]);
return { return {
seriesByKey, 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) { function rangeToMs(range: WorkspaceChartTimeRange) {
switch (range) { switch (range) {
case "15m": case "15m":
@@ -147,26 +192,22 @@ function aggregatePoints(
interval: WorkspaceChartInterval, interval: WorkspaceChartInterval,
): WorkspaceChartPoint[] { ): WorkspaceChartPoint[] {
const bucketMs = intervalToMs(interval); const bucketMs = intervalToMs(interval);
if (bucketMs === 0) {
return points;
}
const buckets = new Map<number, number[]>(); const buckets = new Map<number, number[]>();
for (const point of points) { for (const point of points) {
const time = new Date(point.timestamp).getTime(); const time = new Date(point.timestamp).getTime();
if (!Number.isFinite(time)) { if (!Number.isFinite(time)) continue;
continue;
}
const bucketTime = Math.floor(time / bucketMs) * bucketMs; const bucketTime = Math.floor(time / bucketMs) * bucketMs;
const values = buckets.get(bucketTime) ?? []; 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); buckets.set(bucketTime, values);
} }
@@ -175,9 +216,12 @@ function aggregatePoints(
.map(([bucketTime, values]) => ({ .map(([bucketTime, values]) => ({
timestamp: new Date(bucketTime).toISOString(), timestamp: new Date(bucketTime).toISOString(),
value: average(values), value: average(values),
})); }))
.filter((point) => Number.isFinite(point.value));
} }
function average(values: number[]) { function average(values: number[]) {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length; return values.reduce((sum, value) => sum + value, 0) / values.length;
} }
@@ -2,29 +2,73 @@ import { useEffect, useState } from "react";
import { Client } from "@stomp/stompjs"; import { Client } from "@stomp/stompjs";
import type { TelemetryBroadcastMessage } from "../../../types/telemetry"; import type { TelemetryBroadcastMessage } from "../../../types/telemetry";
const BACKEND_URL = "http://localhost:18450";
export function useTelemetryStream() { export function useTelemetryStream() {
const [message, setMessage] = useState<TelemetryBroadcastMessage | null>(null); const [message, setMessage] = useState<TelemetryBroadcastMessage | null>(null);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
useEffect(() => { 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({ const client = new Client({
brokerURL: "ws://localhost:18450/ws", brokerURL: "ws://localhost:18450/ws",
reconnectDelay: 3000, reconnectDelay: 3000,
onConnect: () => { onConnect: () => {
console.log("[TelemetryStream WS CONNECTED]");
setConnected(true); setConnected(true);
client.subscribe("/topic/telemetry/latest", (frame) => { client.subscribe("/topic/telemetry/latest", (frame) => {
const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage; const payload = JSON.parse(frame.body) as TelemetryBroadcastMessage;
console.log("[TelemetryStream WS MESSAGE]", payload);
setMessage(payload); setMessage(payload);
setInitialLoading(false);
}); });
}, },
onWebSocketClose: () => { onWebSocketClose: () => {
console.warn("[TelemetryStream WS CLOSED]");
setConnected(false); setConnected(false);
}, },
onStompError: () => { onStompError: (frame) => {
console.error("[TelemetryStream STOMP ERROR]", frame);
setConnected(false); setConnected(false);
}, },
}); });
@@ -32,12 +76,14 @@ export function useTelemetryStream() {
client.activate(); client.activate();
return () => { return () => {
client.deactivate(); controller.abort();
void client.deactivate();
}; };
}, []); }, []);
return { return {
connected, connected,
initialLoading,
message, message,
lastTimestamp: message?.timestamp ?? null, lastTimestamp: message?.timestamp ?? null,
snapshots: message?.snapshots ?? [], snapshots: message?.snapshots ?? [],