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; 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(null); const [history, setHistory] = useState({}); const [openMenu, setOpenMenu] = useState(null); const [selectedAccumulated, setSelectedAccumulated] = useState<{ title: string; sensor: ModuleSensorResponse | null; } | null>(null); const [accumulatedRange, setAccumulatedRange] = useState("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 (
} accent="amber" status={temperatureBadge(temperature)} values={history["temperatura.exterior"]} menuOpen={openMenu === "temperature"} onMenuToggle={() => setOpenMenu(openMenu === "temperature" ? null : "temperature") } actions={[ { label: "Ver gráfico", icon: , onClick: () => { setSelectedSensor(temperature ?? null); setOpenMenu(null); }, }, ]} /> } accent="blue" status={humidityBadge(humidity)} values={history["humidade.exterior"]} menuOpen={openMenu === "humidity"} onMenuToggle={() => setOpenMenu(openMenu === "humidity" ? null : "humidity") } actions={[ { label: "Ver gráfico", icon: , onClick: () => { setSelectedSensor(humidity ?? null); setOpenMenu(null); }, }, ]} /> } 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: , onClick: () => { setSelectedSensor(rainSensor ?? null); setOpenMenu(null); }, }, { label: "Ver acumulado", icon: , onClick: () => { setSelectedAccumulated({ title: "Precipitação acumulada", sensor: rainSensor ?? null, }); setOpenMenu(null); }, }, ]} />
} accent="cyan" status={windBadge(windSpeed)} values={history["vento.velocidade"]} menuOpen={openMenu === "wind"} onMenuToggle={() => setOpenMenu(openMenu === "wind" ? null : "wind") } actions={[ { label: "Ver gráfico", icon: , onClick: () => { setSelectedSensor(windSpeed ?? null); setOpenMenu(null); }, }, ]} /> } accent="amber" status={radiationBadge(radiation)} values={history["radiacao.solar"]} menuOpen={openMenu === "radiation"} onMenuToggle={() => setOpenMenu(openMenu === "radiation" ? null : "radiation") } actions={[ { label: "Ver gráfico", icon: , onClick: () => { setSelectedSensor(radiation ?? null); setOpenMenu(null); }, }, { label: "Ver acumulado", icon: , onClick: () => { setSelectedAccumulated({ title: "Radiação solar acumulada", sensor: radiation ?? null, }); setOpenMenu(null); }, }, ]} />
setSelectedSensor(null)} /> setSelectedAccumulated(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: 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 (
{icon}

{title}

{subtitle}

{menuOpen && (
{actions.map((action) => ( ))}
)}
{value} {unit && ( {unit} )}
{status} {trend}
); } 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 (
Direção do vento

{cardinal}

{Array.from({ length: 72 }).map((_, index) => { const major = index % 6 === 0; return ( ); })}
); } function CompassStat({ theme, label, value, highlighted, }: { theme: "dark" | "light"; label: string; value: string; highlighted?: boolean; }) { const isDark = theme === "dark"; return (

{label}

{value}

); } function CompassLabel({ label, className, isDark, }: { label: string; className: string; isDark: boolean; }) { return ( {label} ); } 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 ( ); } 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", }; } }