Add professional historian modal with realtime analytics
This commit is contained in:
@@ -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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user