928 lines
36 KiB
TypeScript
928 lines
36 KiB
TypeScript
import { useEffect, useState, type ReactNode } from "react";
|
|
import {
|
|
Activity,
|
|
ChartNoAxesColumnIncreasing,
|
|
CloudRain,
|
|
Droplets,
|
|
MoreHorizontal,
|
|
Sun,
|
|
Table2,
|
|
Thermometer,
|
|
TrendingUp,
|
|
Wind,
|
|
} from "lucide-react";
|
|
import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream";
|
|
import { MeteoHistoryModal } from "../components/MeteoHistoryModal";
|
|
import type { ModuleSensorResponse } from "../../../types/meteo";
|
|
import { useMeteoHistory } from "../hooks/useMeteoHistory";
|
|
import { AccumulatedHistoryModal } from "../components/AccumulatedHistoryModal";
|
|
import {
|
|
useAccumulatedHistory,
|
|
type AccumulatedRange,
|
|
} from "../hooks/useAccumulatedHistory";
|
|
import { useWeatherForecast } from "../hooks/useWeatherForecast";
|
|
import { WeatherForecastCard } from "../components/WeatherForecastCard";
|
|
|
|
type MeteoPageProps = {
|
|
theme: "dark" | "light";
|
|
};
|
|
|
|
type HistoryMap = Record<string, number[]>;
|
|
type Accent = "amber" | "blue" | "cyan" | "emerald";
|
|
|
|
const MAX_HISTORY_POINTS = 34;
|
|
const RADIUS = "rounded-[5px]";
|
|
|
|
export function MeteoPage({ theme }: MeteoPageProps) {
|
|
const { sensors } = useMeteoModuleStream();
|
|
|
|
const [selectedSensor, setSelectedSensor] =
|
|
useState<ModuleSensorResponse | null>(null);
|
|
|
|
const [history, setHistory] = useState<HistoryMap>({});
|
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
|
|
|
const [selectedAccumulated, setSelectedAccumulated] = useState<{
|
|
title: string;
|
|
sensor: ModuleSensorResponse | null;
|
|
} | null>(null);
|
|
|
|
const [accumulatedRange, setAccumulatedRange] =
|
|
useState<AccumulatedRange>("7d");
|
|
|
|
|
|
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 meteoHistory = useMeteoHistory(selectedSensor);
|
|
|
|
const accumulatedHistory = useAccumulatedHistory(
|
|
selectedAccumulated?.sensor ?? null,
|
|
accumulatedRange,
|
|
);
|
|
|
|
const weatherForecast = useWeatherForecast();
|
|
|
|
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 className="space-y-5 pb-6">
|
|
<WeatherForecastCard
|
|
theme={theme}
|
|
forecast={weatherForecast.forecast}
|
|
loading={weatherForecast.loading}
|
|
error={weatherForecast.error}
|
|
/>
|
|
|
|
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1fr)_420px_minmax(0,1fr)]">
|
|
<div className="grid gap-5 md:grid-cols-2 2xl:grid-cols-1">
|
|
<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: () => {
|
|
setSelectedAccumulated({
|
|
title: "Precipitação acumulada",
|
|
sensor: rainSensor ?? null,
|
|
});
|
|
setOpenMenu(null);
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
<CompassPanel theme={theme} direction={numericValue(windDirection)} />
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-1">
|
|
<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: () => {
|
|
setSelectedAccumulated({
|
|
title: "Radiação solar acumulada",
|
|
sensor: radiation ?? null,
|
|
});
|
|
setOpenMenu(null);
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<MeteoHistoryModal
|
|
sensor={selectedSensor}
|
|
theme={theme}
|
|
points={meteoHistory.points}
|
|
loading={meteoHistory.loading}
|
|
hours={meteoHistory.hours}
|
|
onHoursChange={meteoHistory.setHours}
|
|
onClose={() => setSelectedSensor(null)}
|
|
/>
|
|
|
|
<AccumulatedHistoryModal
|
|
sensor={selectedAccumulated?.sensor ?? null}
|
|
title={selectedAccumulated?.title ?? ""}
|
|
theme={theme}
|
|
buckets={accumulatedHistory.buckets}
|
|
loading={accumulatedHistory.loading}
|
|
range={accumulatedRange}
|
|
onRangeChange={setAccumulatedRange}
|
|
onClose={() => setSelectedAccumulated(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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: Accent;
|
|
status: string;
|
|
values?: number[];
|
|
menuOpen: boolean;
|
|
onMenuToggle: () => void;
|
|
actions: Array<{
|
|
label: string;
|
|
icon: ReactNode;
|
|
onClick: () => void;
|
|
}>;
|
|
}) {
|
|
const isDark = theme === "dark";
|
|
const colors = accentColors(accent, isDark);
|
|
const value = customValue ?? formatValue(sensor);
|
|
const unit = customUnit ?? sensor?.unit;
|
|
const trend = getTrend(values);
|
|
|
|
return (
|
|
<article
|
|
className={
|
|
isDark
|
|
? `${RADIUS} group relative min-h-[190px] overflow-hidden border border-white/10 bg-[#111827] p-5 text-left shadow-[0_12px_30px_rgba(0,0,0,0.22)] transition duration-200 hover:border-white/15 hover:bg-[#141d2b]`
|
|
: `${RADIUS} group relative min-h-[190px] overflow-hidden border border-slate-200 bg-white p-5 text-left shadow-[0_10px_26px_rgba(15,23,42,0.06)] transition duration-200 hover:border-slate-300 hover:shadow-[0_14px_34px_rgba(15,23,42,0.08)]`
|
|
}
|
|
>
|
|
<div className="relative z-20 flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-4">
|
|
<div
|
|
className={`grid h-11 w-11 shrink-0 place-items-center ${RADIUS} border ${colors.iconBox} ${colors.icon}`}
|
|
>
|
|
{icon}
|
|
</div>
|
|
|
|
<div>
|
|
<h2
|
|
className={
|
|
isDark
|
|
? "text-[15px] font-bold tracking-[-0.01em] text-slate-100"
|
|
: "text-[15px] font-bold tracking-[-0.01em] 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 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onMenuToggle();
|
|
}}
|
|
className={
|
|
isDark
|
|
? `grid h-9 w-9 place-items-center ${RADIUS} text-slate-400 transition hover:bg-white/5 hover:text-slate-100`
|
|
: `grid h-9 w-9 place-items-center ${RADIUS} text-slate-400 transition hover:bg-slate-100 hover:text-slate-700`
|
|
}
|
|
aria-label={`Abrir ações de ${title}`}
|
|
>
|
|
<MoreHorizontal className="h-5 w-5" />
|
|
</button>
|
|
|
|
{menuOpen && (
|
|
<div
|
|
className={
|
|
isDark
|
|
? `absolute right-0 top-11 z-50 w-48 ${RADIUS} border border-white/10 bg-[#0f172a] p-1.5 shadow-xl`
|
|
: `absolute right-0 top-11 z-50 w-48 ${RADIUS} border border-slate-200 bg-white p-1.5 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 ${RADIUS} px-3 py-2.5 text-sm font-semibold text-slate-300 transition hover:bg-white/5 hover:text-white`
|
|
: `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 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-8">
|
|
<div className="flex items-end justify-between gap-5">
|
|
<div>
|
|
<div
|
|
className={
|
|
isDark
|
|
? "flex items-end text-[50px] font-black leading-none tracking-[-0.07em] text-slate-100"
|
|
: "flex items-end text-[50px] font-black leading-none tracking-[-0.07em] text-slate-950"
|
|
}
|
|
>
|
|
{value}
|
|
{unit && (
|
|
<span
|
|
className={
|
|
isDark
|
|
? "mb-1 ml-2 text-base font-bold tracking-[-0.02em] text-slate-400"
|
|
: "mb-1 ml-2 text-base font-bold tracking-[-0.02em] text-slate-500"
|
|
}
|
|
>
|
|
{unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-wrap items-center gap-2">
|
|
<span
|
|
className={`inline-flex items-center justify-center ${RADIUS} border px-3 py-1.5 text-xs font-bold ${colors.badge}`}
|
|
>
|
|
{status}
|
|
</span>
|
|
|
|
<span
|
|
className={
|
|
isDark
|
|
? `inline-flex items-center gap-1.5 ${RADIUS} border border-white/10 bg-white/[0.03] px-3 py-1.5 text-xs font-semibold text-slate-400`
|
|
: `inline-flex items-center gap-1.5 ${RADIUS} border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-semibold text-slate-500`
|
|
}
|
|
>
|
|
<TrendingUp className="h-3.5 w-3.5" />
|
|
{trend}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Sparkline
|
|
values={values}
|
|
className="absolute bottom-7 right-6 z-0 h-[62px] w-[220px]"
|
|
strokeClassName={colors.stroke}
|
|
glowClassName={colors.dot}
|
|
/>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<article
|
|
className={
|
|
isDark
|
|
? `${RADIUS} relative flex min-h-[620px] flex-col overflow-hidden border border-white/10 bg-[#111827] p-6 shadow-[0_12px_30px_rgba(0,0,0,0.22)]`
|
|
: `${RADIUS} relative flex min-h-[620px] flex-col overflow-hidden border border-slate-200 bg-white p-6 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`
|
|
}
|
|
>
|
|
<div className="relative z-10 flex flex-col items-center">
|
|
<span
|
|
className={
|
|
isDark
|
|
? `inline-flex items-center gap-2 ${RADIUS} border border-white/10 bg-white/[0.03] px-3 py-1 text-[11px] font-bold uppercase tracking-[0.16em] text-slate-400`
|
|
: `inline-flex items-center gap-2 ${RADIUS} border border-slate-200 bg-slate-50 px-3 py-1 text-[11px] font-bold uppercase tracking-[0.16em] text-slate-500`
|
|
}
|
|
>
|
|
<Activity className="h-3.5 w-3.5" />
|
|
Direção do vento
|
|
</span>
|
|
|
|
<h2
|
|
className={
|
|
isDark
|
|
? "mt-4 text-[42px] font-black leading-none tracking-[-0.08em] text-slate-100"
|
|
: "mt-4 text-[42px] font-black leading-none tracking-[-0.08em] text-slate-950"
|
|
}
|
|
>
|
|
{cardinal}
|
|
</h2>
|
|
|
|
<div className="mt-5 grid w-full grid-cols-2 gap-3">
|
|
<CompassStat
|
|
theme={theme}
|
|
label="Graus"
|
|
value={degrees !== null ? `${degrees}°` : "--"}
|
|
/>
|
|
<CompassStat
|
|
theme={theme}
|
|
label="Quadrante"
|
|
value={directionQuadrant(direction)}
|
|
highlighted
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative z-10 flex flex-1 items-center justify-center pt-6">
|
|
<div className="relative h-[340px] w-[340px] max-w-full">
|
|
<div
|
|
className={
|
|
isDark
|
|
? "absolute inset-0 rounded-full border border-white/10 bg-[#0b1220]"
|
|
: "absolute inset-0 rounded-full border border-slate-200 bg-slate-50"
|
|
}
|
|
/>
|
|
|
|
<div
|
|
className={
|
|
isDark
|
|
? "absolute inset-6 rounded-full border border-white/10"
|
|
: "absolute inset-6 rounded-full border border-slate-200"
|
|
}
|
|
/>
|
|
|
|
<div
|
|
className={
|
|
isDark
|
|
? "absolute inset-[78px] rounded-full border border-white/10 bg-white/[0.02]"
|
|
: "absolute inset-[78px] rounded-full border border-slate-200 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%_158px] ${major ? "h-4" : "h-2"
|
|
} ${isDark ? "bg-slate-600/55" : "bg-slate-300"}`}
|
|
style={{
|
|
transform: `translate(-50%, -158px) rotate(${index * 5}deg)`,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
<CompassLabel label="N" isDark={isDark} className="left-1/2 top-[18px] -translate-x-1/2" />
|
|
<CompassLabel label="S" isDark={isDark} className="bottom-[18px] left-1/2 -translate-x-1/2" />
|
|
<CompassLabel label="W" isDark={isDark} className="left-[22px] top-1/2 -translate-y-1/2" />
|
|
<CompassLabel label="E" isDark={isDark} className="right-[22px] top-1/2 -translate-y-1/2" />
|
|
|
|
<div
|
|
className={
|
|
isDark
|
|
? "absolute inset-[116px] rounded-full border border-white/10 bg-[#111827]"
|
|
: "absolute inset-[116px] rounded-full border border-slate-200 bg-white"
|
|
}
|
|
/>
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="relative h-28 w-28">
|
|
<div
|
|
className={
|
|
isDark
|
|
? "absolute left-1/2 top-1/2 h-[126px] w-[10px] origin-bottom rounded-full bg-slate-300"
|
|
: "absolute left-1/2 top-1/2 h-[126px] w-[10px] origin-bottom rounded-full bg-slate-700"
|
|
}
|
|
style={{
|
|
clipPath: "polygon(50% 0%, 100% 100%, 50% 84%, 0% 100%)",
|
|
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
className={
|
|
isDark
|
|
? "absolute left-1/2 top-1/2 h-[118px] w-[2px] origin-bottom rounded-full bg-white/60"
|
|
: "absolute left-1/2 top-1/2 h-[118px] w-[2px] origin-bottom rounded-full bg-white/90"
|
|
}
|
|
style={{
|
|
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
className={
|
|
isDark
|
|
? "absolute left-1/2 top-1/2 h-10 w-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/10 bg-[#0b1220]"
|
|
: "absolute left-1/2 top-1/2 h-10 w-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 bg-white"
|
|
}
|
|
/>
|
|
|
|
<div
|
|
className={
|
|
isDark
|
|
? "absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-slate-300"
|
|
: "absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-slate-700"
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function CompassStat({
|
|
theme,
|
|
label,
|
|
value,
|
|
highlighted,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
label: string;
|
|
value: string;
|
|
highlighted?: boolean;
|
|
}) {
|
|
const isDark = theme === "dark";
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
isDark
|
|
? `${RADIUS} border border-white/10 bg-white/[0.03] px-4 py-3`
|
|
: `${RADIUS} border border-slate-200 bg-slate-50 px-4 py-3`
|
|
}
|
|
>
|
|
<p
|
|
className={
|
|
isDark
|
|
? "text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500"
|
|
: "text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400"
|
|
}
|
|
>
|
|
{label}
|
|
</p>
|
|
|
|
<p
|
|
className={
|
|
highlighted
|
|
? isDark
|
|
? "mt-1 text-xl font-black tracking-[-0.04em] text-slate-200"
|
|
: "mt-1 text-xl font-black tracking-[-0.04em] text-slate-800"
|
|
: isDark
|
|
? "mt-1 text-xl font-black tracking-[-0.04em] text-slate-100"
|
|
: "mt-1 text-xl font-black tracking-[-0.04em] text-slate-950"
|
|
}
|
|
>
|
|
{value}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CompassLabel({
|
|
label,
|
|
className,
|
|
isDark,
|
|
}: {
|
|
label: string;
|
|
className: string;
|
|
isDark: boolean;
|
|
}) {
|
|
return (
|
|
<span
|
|
className={`absolute flex h-6 w-6 items-center justify-center rounded-full text-[13px] font-black leading-none tracking-[0.02em] ${isDark
|
|
? "bg-white/[0.03] text-slate-300"
|
|
: "bg-white text-slate-500 shadow-sm"
|
|
} ${className}`}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Sparkline({
|
|
values,
|
|
className,
|
|
strokeClassName,
|
|
glowClassName,
|
|
}: {
|
|
values?: number[];
|
|
className?: string;
|
|
strokeClassName: string;
|
|
glowClassName: string;
|
|
}) {
|
|
if (!values || values.length < 2) return null;
|
|
|
|
const width = 220;
|
|
const height = 62;
|
|
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-45 ${className}`}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<polyline
|
|
points={points.join(" ")}
|
|
className={strokeClassName}
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
fill="none"
|
|
/>
|
|
<circle cx={last[0]} cy={last[1]} r="3.5" className={glowClassName} />
|
|
</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 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 getTrend(values?: number[]) {
|
|
if (!values || values.length < 2) return "Sem tendência";
|
|
|
|
const first = values[0];
|
|
const last = values[values.length - 1];
|
|
const delta = last - first;
|
|
|
|
if (Math.abs(delta) < 0.1) return "Estável";
|
|
if (delta > 0) return "A subir";
|
|
return "A descer";
|
|
}
|
|
|
|
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: Accent, isDark: boolean) {
|
|
switch (accent) {
|
|
case "amber":
|
|
return isDark
|
|
? {
|
|
icon: "text-amber-200",
|
|
stroke: "stroke-amber-200",
|
|
dot: "fill-amber-200",
|
|
iconBox: "border-white/10 bg-white/[0.03]",
|
|
badge: "border-white/10 bg-white/[0.03] text-slate-300",
|
|
}
|
|
: {
|
|
icon: "text-amber-700",
|
|
stroke: "stroke-amber-500",
|
|
dot: "fill-amber-500",
|
|
iconBox: "border-slate-200 bg-slate-50",
|
|
badge: "border-slate-200 bg-slate-50 text-slate-600",
|
|
};
|
|
|
|
case "blue":
|
|
return isDark
|
|
? {
|
|
icon: "text-sky-200",
|
|
stroke: "stroke-sky-200",
|
|
dot: "fill-sky-200",
|
|
iconBox: "border-white/10 bg-white/[0.03]",
|
|
badge: "border-white/10 bg-white/[0.03] text-slate-300",
|
|
}
|
|
: {
|
|
icon: "text-sky-700",
|
|
stroke: "stroke-sky-500",
|
|
dot: "fill-sky-500",
|
|
iconBox: "border-slate-200 bg-slate-50",
|
|
badge: "border-slate-200 bg-slate-50 text-slate-600",
|
|
};
|
|
|
|
case "cyan":
|
|
return isDark
|
|
? {
|
|
icon: "text-cyan-200",
|
|
stroke: "stroke-cyan-200",
|
|
dot: "fill-cyan-200",
|
|
iconBox: "border-white/10 bg-white/[0.03]",
|
|
badge: "border-white/10 bg-white/[0.03] text-slate-300",
|
|
}
|
|
: {
|
|
icon: "text-cyan-700",
|
|
stroke: "stroke-cyan-500",
|
|
dot: "fill-cyan-500",
|
|
iconBox: "border-slate-200 bg-slate-50",
|
|
badge: "border-slate-200 bg-slate-50 text-slate-600",
|
|
};
|
|
|
|
case "emerald":
|
|
return isDark
|
|
? {
|
|
icon: "text-emerald-200",
|
|
stroke: "stroke-emerald-200",
|
|
dot: "fill-emerald-200",
|
|
iconBox: "border-white/10 bg-white/[0.03]",
|
|
badge: "border-white/10 bg-white/[0.03] text-slate-300",
|
|
}
|
|
: {
|
|
icon: "text-emerald-700",
|
|
stroke: "stroke-emerald-500",
|
|
dot: "fill-emerald-500",
|
|
iconBox: "border-slate-200 bg-slate-50",
|
|
badge: "border-slate-200 bg-slate-50 text-slate-600",
|
|
};
|
|
}
|
|
} |