Add professional historian modal with realtime analytics

This commit is contained in:
litoral05
2026-05-22 17:08:22 +01:00
parent a30d41d031
commit 6277653fed
9 changed files with 1705 additions and 44 deletions
+837
View File
@@ -0,0 +1,837 @@
import { useEffect, useState, type ReactNode } from "react";
import {
ChartNoAxesColumnIncreasing,
Table2,
CloudRain,
Compass,
Droplets,
MoreHorizontal,
Radio,
Sun,
Thermometer,
Wind,
Wifi,
ChevronRight,
} from "lucide-react";
import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream";
import { MeteoHistoryModal } from "../components/MeteoHistoryModal";
import type { ModuleSensorResponse } from "../../../types/meteo";
import { useMeteoHistory } from "../hooks/useMeteoHistory";
type MeteoPageProps = {
theme: "dark" | "light";
};
type HistoryMap = Record<string, number[]>;
const MAX_HISTORY_POINTS = 34;
export function MeteoPage({ theme }: MeteoPageProps) {
const { sensors, sensorCount, connected, lastTimestamp } =
useMeteoModuleStream();
const [selectedSensor, setSelectedSensor] =
useState<ModuleSensorResponse | null>(null);
const [history, setHistory] = useState<HistoryMap>({});
const isDark = theme === "dark";
const temperature = findSensor(sensors, "temperatura.exterior");
const humidity = findSensor(sensors, "humidade.exterior");
const windDirection = findSensor(sensors, "direcao.vento");
const windSpeed = maxSensor(
sensors.filter((sensor) => sensor.key.startsWith("velocidade.vento.")),
);
const radiation = maxSensor(
sensors.filter((sensor) => sensor.key.startsWith("radiacao.")),
);
const rainSensors = sensors.filter((sensor) => sensor.key.startsWith("chuva."));
const rainSensor =
findSensor(sensors, "chuva.atual") ??
findSensor(sensors, "chuva.instantanea") ??
findSensor(sensors, "chuva.intensidade") ??
maxSensor(rainSensors);
const rainValue = numericValue(rainSensor);
const isRaining = rainValue !== null && rainValue > 0;
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [selectedTable, setSelectedTable] = useState<{
title: string;
sensors: ModuleSensorResponse[];
} | null>(null);
const meteoHistory = useMeteoHistory(selectedSensor);
useEffect(() => {
const samples: Array<[string, number | null]> = [
["temperatura.exterior", numericValue(temperature)],
["humidade.exterior", numericValue(humidity)],
["vento.velocidade", numericValue(windSpeed)],
["radiacao.solar", numericValue(radiation)],
["chuva.total", numericValue(rainSensor)],
];
setHistory((current) => {
const next = { ...current };
for (const [key, value] of samples) {
if (value === null || Number.isNaN(value)) continue;
const previous = next[key] ?? [];
const last = previous[previous.length - 1];
if (last === value && previous.length > 1) continue;
next[key] = [...previous, value].slice(-MAX_HISTORY_POINTS);
}
return next;
});
}, [
temperature?.value,
humidity?.value,
windSpeed?.value,
radiation?.value,
rainSensor?.value,
]);
return (
<>
<div>
<section
className={
isDark
? "rounded-[30px] border border-white/10 bg-[#071827] p-4 shadow-[0_24px_70px_rgba(0,0,0,0.28)]"
: "rounded-[30px] border border-slate-200 bg-slate-50 p-4 shadow-sm"
}
>
<div className="grid gap-4 xl:grid-cols-[1fr_372px_1fr]">
<div className="grid gap-4">
<MetricTile
theme={theme}
title="Temperatura"
subtitle="Temperatura exterior"
sensor={temperature}
icon={<Thermometer className="h-5 w-5" />}
accent="amber"
status={temperatureBadge(temperature)}
values={history["temperatura.exterior"]}
menuOpen={openMenu === "temperature"}
onMenuToggle={() => setOpenMenu(openMenu === "temperature" ? null : "temperature")}
actions={[
{
label: "Ver gráfico",
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
onClick: () => {
setSelectedSensor(temperature ?? null);
setOpenMenu(null);
},
},
]}
/>
<MetricTile
theme={theme}
title="Humidade"
subtitle="Humidade relativa"
sensor={humidity}
icon={<Droplets className="h-5 w-5" />}
accent="blue"
status={humidityBadge(humidity)}
values={history["humidade.exterior"]}
menuOpen={openMenu === "humidity"}
onMenuToggle={() =>
setOpenMenu(openMenu === "humidity" ? null : "humidity")
}
actions={[
{
label: "Ver gráfico",
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
onClick: () => {
setSelectedSensor(humidity ?? null);
setOpenMenu(null);
},
},
]}
/>
<MetricTile
theme={theme}
title="Precipitação"
subtitle="Precipitação atual"
sensor={rainSensor}
icon={<CloudRain className="h-5 w-5" />}
accent="emerald"
status={isRaining ? "A chover" : "Sem chuva"}
values={history["chuva.total"]}
menuOpen={openMenu === "rain"}
onMenuToggle={() =>
setOpenMenu(openMenu === "rain" ? null : "rain")
}
actions={[
{
label: "Ver gráfico",
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
onClick: () => {
setSelectedSensor(rainSensor ?? null);
setOpenMenu(null);
},
},
{
label: "Ver acumulado",
icon: <Table2 className="h-4 w-4" />,
onClick: () => {
setSelectedTable({
title: "Precipitação acumulada",
sensors: rainSensors,
});
setOpenMenu(null);
},
},
]}
/>
</div>
<CompassPanel
theme={theme}
direction={numericValue(windDirection)}
/>
<div className="grid gap-4">
<MetricTile
theme={theme}
title="Vento"
subtitle="Velocidade do vento"
sensor={windSpeed}
icon={<Wind className="h-5 w-5" />}
accent="cyan"
status={windBadge(windSpeed)}
values={history["vento.velocidade"]}
menuOpen={openMenu === "wind"}
onMenuToggle={() =>
setOpenMenu(openMenu === "wind" ? null : "wind")
}
actions={[
{
label: "Ver gráfico",
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
onClick: () => {
setSelectedSensor(windSpeed ?? null);
setOpenMenu(null);
},
},
]}
/>
<MetricTile
theme={theme}
title="Radiação solar"
subtitle="Radiação instantânea"
sensor={radiation}
icon={<Sun className="h-5 w-5" />}
accent="amber"
status={radiationBadge(radiation)}
values={history["radiacao.solar"]}
menuOpen={openMenu === "radiation"}
onMenuToggle={() =>
setOpenMenu(openMenu === "radiation" ? null : "radiation")
}
actions={[
{
label: "Ver gráfico",
icon: <ChartNoAxesColumnIncreasing className="h-4 w-4" />,
onClick: () => {
setSelectedSensor(radiation ?? null);
setOpenMenu(null);
},
},
{
label: "Ver acumulado",
icon: <Table2 className="h-4 w-4" />,
onClick: () => {
setSelectedTable({
title: "Radiação solar acumulada",
sensors: sensors.filter((sensor) =>
sensor.key.startsWith("radiacao."),
),
});
setOpenMenu(null);
},
},
]}
/>
<StatusTile
theme={theme}
connected={connected}
sensorCount={sensorCount}
lastTimestamp={lastTimestamp}
/>
</div>
</div>
</section>
</div>
<MeteoHistoryModal
sensor={selectedSensor}
theme={theme}
points={meteoHistory.points}
loading={meteoHistory.loading}
hours={meteoHistory.hours}
onHoursChange={meteoHistory.setHours}
onClose={() => setSelectedSensor(null)}
/>
</>
);
}
function MetricTile({
theme,
title,
subtitle,
sensor,
customValue,
customUnit,
icon,
accent,
status,
values,
menuOpen,
onMenuToggle,
actions,
}: {
theme: "dark" | "light";
title: string;
subtitle: string;
sensor?: ModuleSensorResponse;
customValue?: string;
customUnit?: string;
icon: ReactNode;
accent: "amber" | "blue" | "cyan" | "emerald";
status: string;
values?: number[];
menuOpen: boolean;
onMenuToggle: () => void;
actions: Array<{
label: string;
icon: ReactNode;
onClick: () => void;
}>;
}) {
const isDark = theme === "dark";
const colors = accentColors(accent);
const value = customValue ?? formatValue(sensor);
const unit = customUnit ?? sensor?.unit;
return (
<div
className={
isDark
? "group relative min-h-[178px] overflow-visible rounded-[22px] border border-[#24384d] bg-[linear-gradient(135deg,#10263b_0%,#0b1d31_48%,#081827_100%)] p-6 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_34px_rgba(0,0,0,0.22)] transition hover:border-[#31506b]"
: "group relative min-h-[178px] overflow-visible rounded-[22px] border border-slate-200 bg-white p-6 text-left shadow-sm transition hover:border-slate-300"
}
>
<div className="relative z-20 flex items-start justify-between">
<div className="flex items-start gap-4">
<div
className={`grid h-12 w-12 place-items-center rounded-2xl border ${isDark
? "border-[#263e56] bg-[#132b43]"
: "border-slate-200 bg-slate-50"
} ${colors.text}`}
>
{icon}
</div>
<div>
<h2
className={
isDark
? "text-[15px] font-bold text-white"
: "text-[15px] font-bold text-slate-950"
}
>
{title}
</h2>
<p
className={
isDark
? "mt-1 text-sm text-slate-400"
: "mt-1 text-sm text-slate-500"
}
>
{subtitle}
</p>
</div>
</div>
<div className="relative">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onMenuToggle();
}}
className={
isDark
? "rounded-lg p-1 text-slate-300 transition hover:bg-white/10 hover:text-white"
: "rounded-lg p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
}
>
<MoreHorizontal className="h-5 w-5" />
</button>
{menuOpen && (
<div
className={
isDark
? "absolute right-0 top-8 z-50 w-44 rounded-xl border border-[#24384d] bg-[#0b2035] p-1 shadow-2xl"
: "absolute right-0 top-8 z-50 w-44 rounded-xl border border-slate-200 bg-white p-1 shadow-xl"
}
>
{actions.map((action) => (
<button
key={action.label}
type="button"
onClick={(event) => {
event.stopPropagation();
action.onClick();
}}
className={
isDark
? "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-semibold text-slate-200 transition hover:bg-white/10 hover:text-white"
: "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 hover:text-slate-950"
}
>
{action.icon}
{action.label}
</button>
))}
</div>
)}
</div>
</div>
<div className="relative z-10 mt-7 flex items-end justify-between gap-5">
<div>
<div
className={`text-[46px] font-black leading-none tracking-[-0.06em] ${colors.text}`}
>
{value}
{unit && (
<span
className={
isDark
? "ml-2 text-sm font-black tracking-normal text-white"
: "ml-2 text-sm font-black tracking-normal text-slate-800"
}
>
{unit}
</span>
)}
</div>
<span
className={`mt-5 inline-flex min-w-[96px] items-center justify-center rounded-xl border px-4 py-2 text-sm font-bold ${isDark
? `${colors.badge} ${colors.text}`
: "border-slate-200 bg-slate-50 text-slate-700"
}`}
>
{status}
</span>
</div>
</div>
<Sparkline
values={values}
className="absolute bottom-8 right-7 z-0 h-[56px] w-[210px]"
strokeClassName={colors.stroke}
glowClassName={colors.dot}
/>
</div>
);
}
function CompassPanel({
theme,
direction,
}: {
theme: "dark" | "light";
direction: number | null;
}) {
const isDark = theme === "dark";
const angle = direction ?? 0;
const cardinal = directionName(direction);
const degrees = direction !== null ? Math.round(direction) : null;
return (
<div
className={
isDark
? "relative flex min-h-[560px] flex-col overflow-hidden rounded-[22px] border border-[#24384d] bg-[linear-gradient(180deg,#0d2236_0%,#081827_100%)] px-6 py-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_34px_rgba(0,0,0,0.22)]"
: "relative flex min-h-[560px] flex-col overflow-hidden rounded-[22px] border border-slate-200 bg-white px-6 py-5 shadow-sm"
}
>
<div className="flex flex-col items-center">
<p className={isDark ? "text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400" : "text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500"}>
Direção do vento
</p>
<h2 className={isDark ? "mt-3 text-[34px] font-black leading-none tracking-[-0.05em] text-white" : "mt-3 text-[34px] font-black leading-none tracking-[-0.05em] text-slate-950"}>
{cardinal}
</h2>
<div className="mt-5 grid w-full grid-cols-2 gap-3">
<div className={isDark ? "rounded-[18px] border border-[#24384d] bg-[#0b2035] px-4 py-3" : "rounded-[18px] border border-slate-200 bg-slate-50 px-4 py-3"}>
<p className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">
Graus
</p>
<p className={isDark ? "mt-1 text-[20px] font-black text-white" : "mt-1 text-[20px] font-black text-slate-950"}>
{degrees !== null ? `${degrees}°` : "--"}
</p>
</div>
<div className={isDark ? "rounded-[18px] border border-[#24384d] bg-[#0b2035] px-4 py-3" : "rounded-[18px] border border-slate-200 bg-slate-50 px-4 py-3"}>
<p className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">
Quadrante
</p>
<p className="mt-1 text-[20px] font-black text-cyan-300">
{directionQuadrant(direction)}
</p>
</div>
</div>
</div>
<div className="flex flex-1 items-center justify-center pt-2">
<div className="relative h-[320px] w-[320px]">
<div className={isDark ? "absolute inset-0 rounded-full border border-[#20364d] bg-[#071827]" : "absolute inset-0 rounded-full border border-slate-200 bg-slate-50"} />
<div className={isDark ? "absolute inset-5 rounded-full border border-[#213b55]" : "absolute inset-5 rounded-full border border-slate-200"} />
<div className={isDark ? "absolute inset-[74px] rounded-full border border-cyan-400/35 bg-[#0a1d30]" : "absolute inset-[74px] rounded-full border border-cyan-500/30 bg-white"} />
{Array.from({ length: 72 }).map((_, index) => {
const major = index % 6 === 0;
return (
<span
key={index}
className={`absolute left-1/2 top-1/2 w-px origin-[50%_149px] ${major ? "h-3" : "h-1.5"
} ${isDark ? "bg-slate-500/50" : "bg-slate-400/60"}`}
style={{
transform: `translate(-50%, -149px) rotate(${index * 5}deg)`,
}}
/>
);
})}
<CompassLabel label="N" className="left-1/2 top-[18px] -translate-x-1/2" />
<CompassLabel label="S" className="bottom-[18px] left-1/2 -translate-x-1/2" />
<CompassLabel label="W" className="left-[22px] top-1/2 -translate-y-1/2" />
<CompassLabel label="E" className="right-[22px] top-1/2 -translate-y-1/2" />
<div className={isDark ? "absolute inset-[108px] rounded-full bg-[#0b2035] shadow-[0_0_24px_rgba(34,211,238,0.08)]" : "absolute inset-[108px] rounded-full bg-white shadow-sm"} />
<div className="absolute inset-0 flex items-center justify-center">
<div
className={
isDark
? "relative h-24 w-24 rounded-full border border-cyan-400/20 bg-[#0b2035]"
: "relative h-24 w-24 rounded-full border border-cyan-500/20 bg-white"
}
>
<div
className="absolute left-1/2 top-1/2 h-[124px] w-[14px] origin-bottom rounded-full bg-cyan-300/90 shadow-[0_0_8px_rgba(34,211,238,0.28)]"
style={{
clipPath: "polygon(50% 0%, 100% 100%, 50% 88%, 0% 100%)",
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
}}
/>
<div
className="absolute left-1/2 top-1/2 h-[116px] w-[5px] origin-bottom rounded-full bg-white/55"
style={{
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
}}
/>
<div
className={
isDark
? "absolute left-1/2 top-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border border-cyan-300/30 bg-[#0d263d]"
: "absolute left-1/2 top-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border border-cyan-500/30 bg-white"
}
/>
<div className="absolute left-1/2 top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-300" />
</div>
</div>
</div>
</div>
</div>
);
}
function CompassLabel({
label,
className,
}: {
label: string;
className: string;
}) {
return (
<span
className={`absolute flex h-5 w-5 items-center justify-center text-[13px] font-black leading-none tracking-[0.02em] text-slate-200 ${className}`}
>
{label}
</span>
);
}
function directionQuadrant(direction: number | null) {
if (direction === null) return "--";
if (direction >= 315 || direction < 45) return "Norte";
if (direction >= 45 && direction < 135) return "Este";
if (direction >= 135 && direction < 225) return "Sul";
return "Oeste";
}
function StatusTile({
theme,
connected,
sensorCount,
lastTimestamp,
}: {
theme: "dark" | "light";
connected: boolean;
sensorCount: number;
lastTimestamp: string | null;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? "min-h-[178px] rounded-[22px] border border-[#24384d] bg-[linear-gradient(135deg,#10263b_0%,#0b1d31_48%,#081827_100%)] p-6 shadow-[inset_0_1px_0_rgba(255,255,255,0.04),0_16px_34px_rgba(0,0,0,0.22)]"
: "min-h-[178px] rounded-[22px] border border-slate-200 bg-white p-6 shadow-sm"
}
>
<div className="mb-6 flex items-center gap-4">
<Radio className="h-5 w-5 text-emerald-300" />
<h2 className={isDark ? "text-[15px] font-bold text-white" : "text-[15px] font-bold text-slate-950"}>
Estado do módulo
</h2>
</div>
<div className="space-y-5">
<StatusRow
theme={theme}
label="Ligação"
value={connected ? "Online" : "Offline"}
online={connected}
/>
<StatusRow theme={theme} label="Sensores" value={String(sensorCount)} />
<StatusRow
theme={theme}
label="Atualização"
value={lastTimestamp ? formatTime(lastTimestamp) : "--"}
/>
</div>
</div>
);
}
function StatusRow({
theme,
label,
value,
online,
}: {
theme: "dark" | "light";
label: string;
value: string;
online?: boolean;
}) {
const isDark = theme === "dark";
return (
<div className="flex items-center justify-between gap-4">
<span className={isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"}>
{label}
</span>
<span className="flex items-center gap-3">
{online !== undefined && (
<Wifi className={online ? "h-4 w-4 text-emerald-300" : "h-4 w-4 text-red-300"} />
)}
<span className={online ? "text-sm font-semibold text-emerald-300" : isDark ? "text-sm font-semibold text-white" : "text-sm font-semibold text-slate-950"}>
{value}
</span>
<ChevronRight className={isDark ? "h-4 w-4 text-slate-500" : "h-4 w-4 text-slate-400"} />
</span>
</div>
);
}
function Sparkline({
values,
className,
strokeClassName,
glowClassName,
}: {
values?: number[];
className?: string;
strokeClassName: string;
glowClassName: string;
}) {
if (!values || values.length < 2) return null;
const width = 210;
const height = 56;
const padding = 8;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const points = values.map((value, index) => {
const x = padding + (index / (values.length - 1)) * (width - padding * 2);
const y = padding + (1 - (value - min) / range) * (height - padding * 2);
return `${x},${y}`;
});
const last = points[points.length - 1].split(",").map(Number);
return (
<svg className={`pointer-events-none opacity-75 ${className}`} viewBox={`0 0 ${width} ${height}`} fill="none">
<polyline
points={points.join(" ")}
className={strokeClassName}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<circle cx={last[0]} cy={last[1]} r="4" className={glowClassName} />
<circle cx={last[0]} cy={last[1]} r="7" className={`${glowClassName} opacity-20`} />
</svg>
);
}
function findSensor(sensors: ModuleSensorResponse[], key: string) {
return sensors.find((sensor) => sensor.key === key);
}
function numericValue(sensor?: ModuleSensorResponse) {
return typeof sensor?.value === "number" ? sensor.value : null;
}
function maxSensor(sensors: ModuleSensorResponse[]) {
return sensors
.filter((sensor) => typeof sensor.value === "number")
.sort((a, b) => Number(b.value) - Number(a.value))[0];
}
function formatValue(sensor?: ModuleSensorResponse) {
if (!sensor) return "--";
if (typeof sensor.value === "number") {
return Number.isInteger(sensor.value)
? String(sensor.value)
: sensor.value.toFixed(1);
}
if (typeof sensor.value === "boolean") {
return sensor.value ? "On" : "Off";
}
if (sensor.value === null || sensor.value === undefined) {
return "--";
}
return String(sensor.value);
}
function directionName(direction: number | null) {
if (direction === null) return "--";
const labels = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
const index = Math.round(direction / 45) % 8;
return labels[index];
}
function temperatureBadge(sensor?: ModuleSensorResponse) {
const value = numericValue(sensor);
if (value === null) return "Sem dados";
if (value >= 30) return "Quente";
if (value <= 10) return "Frio";
return "Normal";
}
function humidityBadge(sensor?: ModuleSensorResponse) {
const value = numericValue(sensor);
if (value === null) return "Sem dados";
if (value >= 80) return "Alta";
if (value <= 35) return "Baixa";
return "Normal";
}
function windBadge(sensor?: ModuleSensorResponse) {
const value = numericValue(sensor);
if (value === null) return "Sem dados";
if (value >= 30) return "Forte";
if (value >= 10) return "Moderado";
return "Fraco";
}
function radiationBadge(sensor?: ModuleSensorResponse) {
const value = numericValue(sensor);
if (value === null) return "Sem dados";
if (value >= 800) return "Alta";
if (value >= 400) return "Média";
return "Baixa";
}
function accentColors(accent: "amber" | "blue" | "cyan" | "emerald") {
switch (accent) {
case "amber":
return {
text: "text-amber-300",
stroke: "stroke-amber-400",
dot: "fill-amber-400",
badge: "border-amber-300/20 bg-amber-400/10",
};
case "blue":
return {
text: "text-sky-300",
stroke: "stroke-sky-400",
dot: "fill-sky-400",
badge: "border-sky-300/20 bg-sky-400/10",
};
case "cyan":
return {
text: "text-cyan-300",
stroke: "stroke-cyan-400",
dot: "fill-cyan-400",
badge: "border-cyan-300/20 bg-cyan-400/10",
};
case "emerald":
return {
text: "text-emerald-300",
stroke: "stroke-emerald-400",
dot: "fill-emerald-400",
badge: "border-emerald-300/20 bg-emerald-400/10",
};
}
}
function formatTime(timestamp: string) {
return new Date(timestamp).toLocaleTimeString("pt-PT", {
hour: "2-digit",
minute: "2-digit",
});
}