Implements meteoChartsPage, fixed responsiveness overall
This commit is contained in:
@@ -6,7 +6,8 @@
|
|||||||
"main",
|
"main",
|
||||||
"chart-*",
|
"chart-*",
|
||||||
"maincharts-*",
|
"maincharts-*",
|
||||||
"climatecharts-*"
|
"climatecharts-*",
|
||||||
|
"meteocharts-*"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
|||||||
+17
-1
@@ -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} />;
|
||||||
|
|||||||
@@ -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 = {
|
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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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 ?? [],
|
||||||
|
|||||||
Reference in New Issue
Block a user