Implements meteoChartsPage, fixed responsiveness overall
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"main",
|
||||
"chart-*",
|
||||
"maincharts-*",
|
||||
"climatecharts-*"
|
||||
"climatecharts-*",
|
||||
"meteocharts-*"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
|
||||
+17
-1
@@ -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 (
|
||||
<AppShell activePage={activePage} onNavigate={setActivePage}>
|
||||
{({ 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") {
|
||||
return <ClimateChartsPage theme={theme} />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)]`
|
||||
}
|
||||
>
|
||||
<header
|
||||
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">
|
||||
{dragHandle && <div className="mt-1 shrink-0">{dragHandle}</div>}
|
||||
@@ -356,25 +363,15 @@ export function WorkspaceChart({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
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"
|
||||
}
|
||||
>
|
||||
<main className="flex min-h-0 flex-1 flex-col px-3 pb-3 sm:px-4 sm:pb-4">
|
||||
<section
|
||||
className={
|
||||
detached
|
||||
? isDark
|
||||
? `${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-3 sm:p-4`
|
||||
: isDark
|
||||
? `${RADIUS} bg-[#09111F] p-3 sm:p-4`
|
||||
: `${RADIUS} bg-slate-50 p-3 sm:p-4`
|
||||
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-slate-50 p-2 sm:p-3`
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -572,7 +569,7 @@ export function WorkspaceChart({
|
||||
</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]">
|
||||
{visibleVariables.map((variable) => (
|
||||
<span
|
||||
@@ -611,13 +608,7 @@ export function WorkspaceChart({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
detached
|
||||
? "relative min-h-0 flex-1"
|
||||
: "relative h-[340px] min-h-[340px]"
|
||||
}
|
||||
>
|
||||
<div className="relative min-h-[80px] flex-1">
|
||||
{visibleVariables.length === 0 && !shouldShowLoading ? (
|
||||
<EmptyChartMessage message="Escolha pelo menos uma variável." />
|
||||
) : data.length === 0 && !shouldShowLoading ? (
|
||||
@@ -625,11 +616,12 @@ export function WorkspaceChart({
|
||||
) : data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chart.mode === "bar" ? (
|
||||
<BarChart data={data}>
|
||||
<BarChart data={displayData} barCategoryGap="25%" barGap={4}>
|
||||
<ChartScaffold
|
||||
isDark={isDark}
|
||||
yDomain={yDomain}
|
||||
yAxes={yAxes}
|
||||
chartTimeRange={chart.timeRange}
|
||||
/>
|
||||
{visibleVariables.map((variable) => (
|
||||
<Bar
|
||||
@@ -642,15 +634,17 @@ export function WorkspaceChart({
|
||||
radius={[3, 3, 0, 0]}
|
||||
opacity={0.78}
|
||||
isAnimationActive={false}
|
||||
maxBarSize={42}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
) : chart.mode === "area" ? (
|
||||
<AreaChart data={data}>
|
||||
<AreaChart data={displayData}>
|
||||
<ChartScaffold
|
||||
isDark={isDark}
|
||||
yDomain={yDomain}
|
||||
yAxes={yAxes}
|
||||
chartTimeRange={chart.timeRange}
|
||||
/>
|
||||
{renderReferenceLines(
|
||||
visibleVariables,
|
||||
@@ -677,11 +671,12 @@ export function WorkspaceChart({
|
||||
))}
|
||||
</AreaChart>
|
||||
) : (
|
||||
<LineChart data={data}>
|
||||
<LineChart data={displayData}>
|
||||
<ChartScaffold
|
||||
isDark={isDark}
|
||||
yDomain={yDomain}
|
||||
yAxes={yAxes}
|
||||
chartTimeRange={chart.timeRange}
|
||||
/>
|
||||
{renderReferenceLines(
|
||||
visibleVariables,
|
||||
@@ -720,7 +715,7 @@ export function WorkspaceChart({
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<InlineMetric
|
||||
label="Atual"
|
||||
@@ -766,10 +761,12 @@ function ChartScaffold({
|
||||
isDark,
|
||||
yDomain,
|
||||
yAxes,
|
||||
chartTimeRange
|
||||
}: {
|
||||
isDark: boolean;
|
||||
yDomain: [number | "auto", number | "auto"];
|
||||
yAxes: YAxisConfig[];
|
||||
chartTimeRange: WorkspaceChartTimeRange;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -780,15 +777,21 @@ function ChartScaffold({
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
dataKey="timestampMs"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
padding={{ left: 24, right: 24 }}
|
||||
tick={{ fill: "#64748b", fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={{
|
||||
stroke: isDark ? "rgba(148,163,184,0.12)" : "#cbd5e1",
|
||||
}}
|
||||
minTickGap={34}
|
||||
tickFormatter={(value) =>
|
||||
formatAxisTime(new Date(Number(value)).toISOString(), chartTimeRange)
|
||||
}
|
||||
/>
|
||||
|
||||
{yAxes.map((axis) => (
|
||||
<YAxis
|
||||
key={axis.id}
|
||||
@@ -985,7 +988,15 @@ function formatModeLabel(mode: WorkspaceChartMode) {
|
||||
function formatAxisTime(timestamp: string, range: WorkspaceChartTimeRange) {
|
||||
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", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
@@ -997,7 +1008,6 @@ function formatAxisTime(timestamp: string, range: WorkspaceChartTimeRange) {
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatTooltipDate(timestamp: string) {
|
||||
return new Date(timestamp).toLocaleString("pt-PT", {
|
||||
day: "2-digit",
|
||||
@@ -1099,4 +1109,21 @@ function normalizeUnit(unit?: string): string {
|
||||
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;
|
||||
@@ -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) {
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "fixed inset-0 overflow-hidden bg-[#0b1220] text-slate-100"
|
||||
: "fixed inset-0 overflow-hidden bg-slate-100 text-slate-950"
|
||||
? "fixed inset-0 overflow-hidden bg-[#07101B] text-slate-100"
|
||||
: "fixed inset-0 overflow-hidden bg-white text-slate-950"
|
||||
}
|
||||
>
|
||||
<style>{`
|
||||
.app-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${isDark ? "#334155 #0b1220" : "#94a3b8 #f1f5f9"};
|
||||
scrollbar-color: ${isDark ? "#334155 #07101B" : "#94a3b8 #f8fafc"};
|
||||
}
|
||||
|
||||
.app-scrollbar::-webkit-scrollbar {
|
||||
@@ -63,14 +64,14 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
||||
}
|
||||
|
||||
.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"};
|
||||
}
|
||||
|
||||
.app-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: ${isDark ? "#334155" : "#94a3b8"};
|
||||
border-radius: 999px;
|
||||
border: 2px solid ${isDark ? "#0b1220" : "#f1f5f9"};
|
||||
border: 2px solid ${isDark ? "#07101B" : "#f8fafc"};
|
||||
}
|
||||
|
||||
.app-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
@@ -115,17 +116,25 @@ export function AppShell({ activePage, onNavigate, children }: AppShellProps) {
|
||||
<main
|
||||
className={
|
||||
isDark
|
||||
? `app-scrollbar min-h-0 flex-1 border-t border-white/10 bg-[#0b1220] ${activePage === "dashboard"
|
||||
? "overflow-hidden p-0"
|
||||
: "overflow-y-auto p-4"
|
||||
? `app-scrollbar min-h-0 flex-1 border-t border-white/10 ${isDashboard
|
||||
? "overflow-y-auto bg-[#07101B] p-0"
|
||||
: "overflow-y-auto bg-[#0b1220] p-4"
|
||||
}`
|
||||
: `app-scrollbar min-h-0 flex-1 border-t border-slate-200 bg-slate-100 ${activePage === "dashboard"
|
||||
? "overflow-hidden p-0"
|
||||
: "overflow-y-auto p-4"
|
||||
: `app-scrollbar min-h-0 flex-1 border-t border-slate-200 ${isDashboard
|
||||
? "overflow-y-auto bg-white p-0"
|
||||
: "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({
|
||||
theme,
|
||||
snapshots: telemetry.snapshots,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -120,15 +120,16 @@ export function Sidebar({
|
||||
<aside
|
||||
className={
|
||||
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-[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-[#263247] bg-[#0B1220] text-slate-100 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
|
||||
className={
|
||||
collapsed
|
||||
? "mb-10 flex items-center justify-center"
|
||||
: "mb-10 flex items-center gap-3 px-1"
|
||||
? "mb-6 flex items-center justify-center"
|
||||
: "mb-6 flex items-center gap-3 px-1"
|
||||
}
|
||||
>
|
||||
<div
|
||||
@@ -163,8 +164,9 @@ export function Sidebar({
|
||||
</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
|
||||
theme={theme}
|
||||
collapsed={collapsed}
|
||||
@@ -276,13 +278,14 @@ export function Sidebar({
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 px-4 pb-5 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={
|
||||
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`
|
||||
: `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-[#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-[#CBD5E1] bg-white px-4 py-3 text-[14px] font-semibold text-slate-600 transition hover:bg-[#EAF0F7] hover:text-[#0F172A]`
|
||||
}
|
||||
>
|
||||
{collapsed ? (
|
||||
@@ -294,6 +297,7 @@ export function Sidebar({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ export async function openChartWindow(
|
||||
chartId: string,
|
||||
theme: "dark" | "light",
|
||||
title: string,
|
||||
scope: "GLOBAL" | "CLIMATE" = "GLOBAL",
|
||||
channel: "maincharts" | "climatecharts" = "maincharts",
|
||||
scope: "GLOBAL" | "CLIMATE" | "METEO" = "GLOBAL",
|
||||
channel: "maincharts" | "climatecharts" | "meteocharts" = "maincharts",
|
||||
) {
|
||||
const label = `${channel}-${chartId}`;
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export function useClimateChartSeries(
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
@@ -22,35 +23,15 @@ type DashboardPageProps = {
|
||||
|
||||
const RADIUS = "rounded-[6px]";
|
||||
|
||||
export function DashboardPage({
|
||||
theme,
|
||||
onOpenMeteo,
|
||||
onNavigate,
|
||||
}: DashboardPageProps) {
|
||||
export function DashboardPage({ theme, onOpenMeteo, onNavigate }: DashboardPageProps) {
|
||||
const isDark = theme === "dark";
|
||||
const weather = useWeatherForecast();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
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 className={isDark ? "relative h-full text-slate-100" : "relative h-full text-[#0F172A]"}>
|
||||
<img src={backgroundImage} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute inset-0 bg-[#07101B]/62"
|
||||
: "absolute inset-0 bg-white/20"
|
||||
}
|
||||
/>
|
||||
<div className={isDark ? "absolute inset-0 bg-[#07101B]/62" : "absolute inset-0 bg-white/20"} />
|
||||
|
||||
<div
|
||||
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">
|
||||
<section className="relative min-h-0 pb-10">
|
||||
<div className="max-w-[620px] pt-5">
|
||||
<h1
|
||||
className={
|
||||
isDark
|
||||
? "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-white"
|
||||
: "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-[#0F172A]"
|
||||
<style>{`
|
||||
.dashboard-weather-card > * {
|
||||
position: relative !important;
|
||||
inset: auto !important;
|
||||
right: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
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
|
||||
<br />
|
||||
<span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>
|
||||
Litoral Central
|
||||
</span>
|
||||
<span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>Litoral Central</span>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-7 h-[2px] w-14 bg-[#4FD1C5]"
|
||||
: "mt-7 h-[2px] w-14 bg-[#0F766E]"
|
||||
}
|
||||
/>
|
||||
<div className={isDark ? "mt-5 h-[2px] w-14 bg-[#4FD1C5]" : "mt-5 h-[2px] w-14 bg-[#0F766E]"} />
|
||||
|
||||
<p
|
||||
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"
|
||||
}
|
||||
>
|
||||
<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"}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMeteo}
|
||||
className={
|
||||
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]`
|
||||
}
|
||||
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]`}
|
||||
>
|
||||
Explorar plataforma
|
||||
<ArrowRight
|
||||
className={
|
||||
isDark
|
||||
? "h-5 w-5 text-[#4FD1C5]"
|
||||
: "h-5 w-5 text-[#0F766E]"
|
||||
}
|
||||
/>
|
||||
<ArrowRight className={isDark ? "h-5 w-5 text-[#4FD1C5]" : "h-5 w-5 text-[#0F766E]"} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-weather-card flex justify-end pt-3">
|
||||
<WeatherForecastCard
|
||||
compact
|
||||
theme={theme}
|
||||
@@ -125,119 +88,34 @@ export function DashboardPage({
|
||||
error={weather.error}
|
||||
onOpenMeteo={onOpenMeteo}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid min-h-0 grid-cols-3 gap-5">
|
||||
<InfoCard
|
||||
theme={theme}
|
||||
icon={<CloudSun className="h-7 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")}
|
||||
/>
|
||||
|
||||
<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 className="grid grid-cols-3 gap-3 xl:gap-4">
|
||||
<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")} />
|
||||
<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")} />
|
||||
<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")} />
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? `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`
|
||||
: `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>
|
||||
<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`}>
|
||||
<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)]">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
|
||||
<h2
|
||||
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]"
|
||||
}
|
||||
>
|
||||
<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]"}>
|
||||
Soluções inovadoras para
|
||||
<br />
|
||||
uma{" "}
|
||||
<span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>
|
||||
agricultura sustentável
|
||||
</span>
|
||||
uma <span className={isDark ? "text-[#4FD1C5]" : "text-[#0F766E]"}>agricultura sustentável</span>
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className={
|
||||
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 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"}>
|
||||
Tecnologia, conhecimento e proximidade para impulsionar o futuro do setor agrícola em Portugal.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "mt-6 grid grid-cols-3 gap-5 border-t border-[#263247] pt-6"
|
||||
: "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 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"}>
|
||||
<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>
|
||||
|
||||
@@ -245,126 +123,55 @@ export function DashboardPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer
|
||||
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"
|
||||
}
|
||||
>
|
||||
<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"}>
|
||||
<span>© 2026 Litoral Central. Todos os direitos reservados.</span>
|
||||
<span>
|
||||
Feito em Portugal <span className="ml-2">🇵🇹</span>
|
||||
</span>
|
||||
<span>Feito em Portugal <span className="ml-2">🇵🇹</span></span>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
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}`}
|
||||
>
|
||||
<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`}>
|
||||
<div className="flex min-w-0 items-center gap-3 xl:gap-4">
|
||||
<div className={`grid h-[50px] w-[50px] shrink-0 place-items-center ${RADIUS} ${iconClass} xl:h-[58px] xl:w-[58px]`}>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3
|
||||
className={
|
||||
isDark
|
||||
? "text-base font-black text-white"
|
||||
: "text-base font-black text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h3 className={isDark ? "truncate text-sm font-black text-white xl:text-base" : "truncate text-sm font-black text-[#0F172A] xl:text-base"}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
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"
|
||||
}
|
||||
>
|
||||
<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"}>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight
|
||||
className={
|
||||
isDark
|
||||
? "h-5 w-5 shrink-0 text-[#4FD1C5]"
|
||||
: "h-5 w-5 shrink-0 text-[#0F766E]"
|
||||
}
|
||||
/>
|
||||
<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"} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={isDark ? "h-7 w-7 text-[#4FD1C5]" : "h-7 w-7 text-[#0F766E]"}>
|
||||
<div className="flex min-w-0 items-center gap-2 xl:gap-3">
|
||||
<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}
|
||||
</div>
|
||||
<div>
|
||||
<h4
|
||||
className={
|
||||
isDark
|
||||
? "text-sm font-black text-white"
|
||||
: "text-sm font-black text-[#0F172A]"
|
||||
}
|
||||
>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h4 className={isDark ? "truncate text-xs font-black text-white xl:text-sm" : "truncate text-xs font-black text-[#0F172A] xl:text-sm"}>
|
||||
{title}
|
||||
</h4>
|
||||
<p
|
||||
className={
|
||||
isDark
|
||||
? "mt-1 text-xs text-[#7F8CA3]"
|
||||
: "mt-1 text-xs text-slate-600"
|
||||
}
|
||||
>
|
||||
<p className={isDark ? "mt-1 truncate text-[11px] text-[#7F8CA3] xl:text-xs" : "mt-1 truncate text-[11px] text-slate-600 xl:text-xs"}>
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
@@ -373,18 +180,12 @@ function MissionItem({
|
||||
}
|
||||
|
||||
function FarmIllustration({ theme }: { theme: "dark" | "light" }) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
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
|
||||
src={isDark ? farmdrawImage : farmdrawWhiteImage}
|
||||
src={theme === "dark" ? farmdrawImage : farmdrawWhiteImage}
|
||||
alt=""
|
||||
className={
|
||||
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"
|
||||
}
|
||||
className="absolute right-[-10px] top-1/2 h-[280px] max-w-none -translate-y-1/2 opacity-95 xl:h-[330px] 2xl:h-[360px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -536,7 +536,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
};
|
||||
}, [layoutMode]);
|
||||
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
|
||||
className={
|
||||
isDark
|
||||
@@ -860,7 +860,7 @@ function WorkspaceChartContainer({
|
||||
? "relative rounded-[7px] ring-2 ring-[#4FD1C5]/50 transition"
|
||||
: isMoving
|
||||
? "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">
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
const sensorKey = sensor.key
|
||||
|
||||
async function loadAccumulated() {
|
||||
try {
|
||||
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 };
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
<RealtimeChartPanel
|
||||
theme={theme}
|
||||
onOpenMeteoCharts={onOpenMeteoCharts}
|
||||
series={chartSeries}
|
||||
historyLoading={historyLoading}
|
||||
hours={HISTORY_HOURS}
|
||||
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<WorkspaceChartMode>("line");
|
||||
|
||||
const [timeRange, setTimeRange] = useState<WorkspaceChartTimeRange>("6h");
|
||||
const [interval, setInterval] = useState<WorkspaceChartInterval>("5m");
|
||||
const [visibleKeys, setVisibleKeys] = useState<HistoryKey[]>([
|
||||
@@ -1212,6 +1218,7 @@ function RealtimeChartPanel({
|
||||
<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"
|
||||
onClick={onOpenMeteoCharts}
|
||||
>
|
||||
Gráficos Personalizados
|
||||
</button>
|
||||
|
||||
@@ -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<Record<string, WorkspaceChartPoint[]>>({});
|
||||
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<number, number[]>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<TelemetryBroadcastMessage | null>(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 ?? [],
|
||||
|
||||
Reference in New Issue
Block a user