import { useEffect, useMemo, useState } from "react"; import { Activity, Cloud, CloudRain, Droplets, Gauge, Compass, Eye, Sun, Zap, Thermometer, Wind, X, type LucideIcon, } from "lucide-react"; import weatherBoardBackground from "../../../assets/meteo-pane.png"; import sunnyBackground from "../../../assets/weather/Sunny.png"; import partlyCloudyBackground from "../../../assets/weather/Partly Cloudy.png"; import cloudyBackground from "../../../assets/weather/Cloudy.png"; import rainyBackground from "../../../assets/weather/Rainy.png"; import overcastBackground from "../../../assets/weather/Overcast.png"; import foggyBackground from "../../../assets/weather/Foggy.png"; import patchyRainBackground from "../../../assets/weather/Patchy Rain.png"; import thunderstormBackground from "../../../assets/weather/Thunderstorm.png"; import snowBackground from "../../../assets/weather/Snow.png"; import snowstormBackground from "../../../assets/weather/Snowstorm.png"; import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream"; import { useMeteoMultiHistory } from "../hooks/useMeteoMultiHistory"; import { useWeatherForecast } from "../hooks/useWeatherForecast"; import type { WeatherForecastResponse } from "../../../types/weather"; import { numericSensorValue, selectMeteoSensors, } from "../domain/meteoSensorSelectors"; import WorkspaceChart, { type WorkspaceChartConfig, type WorkspaceChartInterval, type WorkspaceChartMode, type WorkspaceChartTimeRange, } from "../../../components/charts/WorkspaceChart"; import type { HistorianPoint } from "../components/MeteoHistoryModal"; import { useAccumulatedHistory } from "../hooks/useAccumulatedHistory"; import type { AccumulatedBucket } from "../hooks/useAccumulatedHistory"; type MeteoPageProps = { theme: "dark" | "light"; onOpenMeteoCharts: () => void; }; type ChartPoint = { timestamp: string; value: number; }; type ChartSeries = { key: HistoryKey; title: string; shortTitle: string; unit: string; values: ChartPoint[]; accent: Accent; }; type Accent = "amber" | "blue" | "cyan" | "emerald" | "violet"; const HISTORY_HOURS = 6; const RADIUS = "rounded-[5px]"; const HISTORY_KEYS = { temperature: "temperature", humidity: "humidity", windSpeed: "windSpeed", windDirection: "windDirection", radiation: "radiation", } as const; type HistoryKey = (typeof HISTORY_KEYS)[keyof typeof HISTORY_KEYS]; export function MeteoPage({ theme, onOpenMeteoCharts }: MeteoPageProps) { const { sensors } = useMeteoModuleStream(); const weatherForecast = useWeatherForecast(); const [weatherBoardOpen, setWeatherBoardOpen] = useState(false); const selected = selectMeteoSensors(sensors); const temperature = selected.temperature; const humidity = selected.humidity; const windDirection = selected.windDirection; const windSpeed = selected.windSpeed; const co2 = selected.co2; const radiation = selected.radiation; const forecast = weatherForecast.forecast; const { buckets: rainBuckets } = useAccumulatedHistory(selected.rain ?? null, "7d"); const { buckets: radiationBuckets } = useAccumulatedHistory(radiation ?? null, "7d"); const rainTodayBucket = todayAccumulatedBucket(rainBuckets); const radiationTodayBucket = todayAccumulatedBucket(radiationBuckets); const accumulatedRainToday = formatAccumulatedValue( rainTodayBucket?.total ?? null, rainTodayBucket?.unit ?? "mm", ); const accumulatedRadiationToday = formatAccumulatedValue( radiationTodayBucket?.total ?? null, radiationTodayBucket?.unit, ); const { pointsByKey, loading: historyLoading } = useMeteoMultiHistory( [ temperature ?? null, humidity ?? null, windSpeed ?? null, windDirection ?? null, radiation ?? null, ], HISTORY_HOURS, ); const [selectedForecastDayIndex, setSelectedForecastDayIndex] = useState(0); const forecastDays = forecast?.daily ?? []; const selectedForecastDay = forecastDays[selectedForecastDayIndex] ?? forecastDays[0] ?? null; useEffect(() => { if (!forecastDays.length) { setSelectedForecastDayIndex(0); return; } if (selectedForecastDayIndex > forecastDays.length - 1) { setSelectedForecastDayIndex(0); } }, [forecastDays.length, selectedForecastDayIndex]); // Live station readings only. Forecast values are handled by WeatherHeroPanel. const currentTemperature = numericSensorValue(temperature); const currentHumidity = numericSensorValue(humidity); const currentWindSpeed = numericSensorValue(windSpeed); const currentRadiation = numericSensorValue(radiation); const currentWindDirection = numericSensorValue(windDirection); const currentCo2 = numericSensorValue(co2) const currentDewPoint = calculateDewPoint( currentTemperature, currentHumidity, ); const chartSeries = useMemo( () => [ { key: HISTORY_KEYS.temperature, title: "Temperatura (°C)", shortTitle: "Temperatura", unit: temperature?.unit ?? "°C", values: historianSeries(pointsByKey[temperature?.key ?? ""]), accent: "amber" as Accent, }, { key: HISTORY_KEYS.humidity, title: "Humidade (%)", shortTitle: "Humidade", unit: humidity?.unit ?? "%", values: historianSeries(pointsByKey[humidity?.key ?? ""]), accent: "blue" as Accent, }, { key: HISTORY_KEYS.windSpeed, title: "Vento (km/h)", shortTitle: "Vento", unit: windSpeed?.unit ?? "km/h", values: historianSeries(pointsByKey[windSpeed?.key ?? ""]), accent: "emerald" as Accent, }, { key: HISTORY_KEYS.radiation, title: "Radiação (W/m²)", shortTitle: "Radiação", unit: radiation?.unit ?? "W/m²", values: historianSeries(pointsByKey[radiation?.key ?? ""]), accent: "violet" as Accent, }, ], [ pointsByKey, humidity?.key, humidity?.unit, radiation?.key, radiation?.unit, temperature?.key, temperature?.unit, windSpeed?.key, windSpeed?.unit, ], ); const windDirectionHistory = useMemo( () => historianSeries(pointsByKey[windDirection?.key ?? ""]), [pointsByKey, windDirection?.key], ); return (
setWeatherBoardOpen(true)} />
{weatherBoardOpen && ( setWeatherBoardOpen(false)} /> )}
); } function WeatherBoardModal({ temperature, humidity, windSpeed, windDirection, co2, radiation, dewPoint, accumulatedRain, // ← add accumulatedRadiation, // ← add onClose, }: { theme: "dark" | "light"; temperature: number | null; humidity: number | null; windSpeed: number | null; co2: number | null; windDirection: number | null; radiation: number | null; dewPoint: number | null; accumulatedRain: { value: string; unit: string }; accumulatedRadiation: { value: string; unit: string }; onClose: () => void; }) { const angle = windDirection ?? 0; const cardinal = directionName(windDirection); return (
{/* Needle pointing up (north = 0°) */} {/* Tail */} {/* Center dot */}
{/* Degree label */}
{Math.round(windDirection ?? 0)}°
); } function todayAccumulatedBucket(buckets: AccumulatedBucket[]) { const now = new Date(); const todayLabel = `${String(now.getDate()).padStart(2, "0")}/${String( now.getMonth() + 1, ).padStart(2, "0")}`; return buckets.find((bucket) => bucket.label === todayLabel) ?? null; } function formatAccumulatedValue(value: number | null, unit?: string) { if (value === null) return { value: "--", unit: "" }; if (unit === "Wh/m²") { return { value: value.toFixed(1), unit: "kWh/m²", }; } return { value: value.toFixed(1), unit: unit ?? "", }; } function BoardText({ className, value, }: { className: string; value: string; }) { return (
{value}
); } function BoardMetric({ className, icon: Icon, label, value, unit, color, }: { className: string; icon: LucideIcon; label: string; value: string; unit: string; color: "blue" | "cyan" | "yellow" | "orange" | "white" | "muted"; }) { const valueColor = color === "blue" ? "text-blue-400" : color === "cyan" ? "text-cyan-300" : color === "yellow" ? "text-yellow-300" : color === "orange" ? "text-orange-500" : color === "muted" ? "text-slate-300" : "text-white"; return (

{label}

{value} {unit && ( ({unit}) )}

); } function WeatherHeroPanel({ theme, forecast, selectedDay, selectedDayIndex, loading, error, onOpenWeatherBoard, }: { theme: "dark" | "light"; forecast: WeatherForecastResponse | null; selectedDay: WeatherForecastResponse["daily"][number] | null; selectedDayIndex: number; loading: boolean; error: string | null; onOpenWeatherBoard: () => void; }) { const isDark = theme === "dark"; const day = selectedDay ?? forecast?.daily?.[0]; if (!day) { return Sem previsão disponível.; } const location = forecast ? `${forecast.location.name}, ${forecast.location.country}` : "Mira, Portugal"; const conditionText = day.condition?.text ?? "--"; const heroBackground = weatherBackgroundForCondition(conditionText); const heroTemperature = day.maxTemperatureC ?? null; const heroMinTemperature = day.minTemperatureC ?? null; const heroDateLabel = selectedDayIndex === 0 ? "Hoje" : selectedDayIndex === 1 ? "Amanhã" : weekday(day.date); return (

{location}

{selectedDayIndex === 0 ? "Condições de hoje" : `Previsão para ${heroDateLabel}`}

{loading ? ( A carregar meteorologia... ) : error ? ( {error} ) : (
{day.condition?.icon ? ( {day.condition.text} ) : ( )}
{formatNumber(heroTemperature, 1)}°

{conditionText}

{heroMinTemperature !== null ? `Mínima ${formatNumber(heroMinTemperature, 0)}°` : "Sem mínima disponível"}

)}
); } function ForecastPanel({ theme, forecast, loading, error, selectedIndex, onSelectDay, }: { theme: "dark" | "light"; forecast: WeatherForecastResponse | null; loading: boolean; error: string | null; selectedIndex: number; onSelectDay: (index: number) => void; }) { const isDark = theme === "dark"; const days = forecast?.daily?.slice(0, 7) ?? []; const selectedDay = days[selectedIndex] ?? days[0] ?? null; return (

Previsão diária

Próximos 7 dias

{selectedDay && (

Dia selecionado

{weekday(selectedDay.date)} · {shortDate(selectedDay.date)}

)}
{loading ? ( A carregar previsão... ) : error ? ( {error} ) : !days.length ? ( Sem previsão diária disponível. ) : (
{days.map((day, index) => ( onSelectDay(index)} /> ))}
)}
); } function ForecastDayCard({ theme, day, index, active, onClick, }: { theme: "dark" | "light"; day: WeatherForecastResponse["daily"][number]; index: number; active: boolean; onClick: () => void; }) { const isDark = theme === "dark"; const label = index === 0 ? "Hoje" : index === 1 ? "Amanhã" : weekday(day.date); const conditionText = day.condition?.text ?? ""; const background = weatherBackgroundForCondition(conditionText); return ( ); } function WeatherSummaryPanel({ theme, temperature, humidity, windSpeed, dewPoint, temperatureUnit, humidityUnit, windUnit, radiation, radiationUnit }: { theme: "dark" | "light"; temperature: number | null; humidity: number | null; windSpeed: number | null; dewPoint: number | null; temperatureUnit: string; humidityUnit: string; windUnit: string; radiation: number | null; radiationUnit: string; }) { const isDark = theme === "dark"; return (

Resumo meteorológico

); } function radiationBadge(value: number | null) { if (value === null) return "Sem dados"; if (value >= 800) return "Alta"; if (value >= 400) return "Média"; return "Baixa"; } function WindDirectionPanel({ theme, direction, history, windHistory, }: { theme: "dark" | "light"; direction: number | null; history: Array<{ timestamp: string; value: number; }>; windHistory: Array<{ timestamp: string; value: number; }>; }) { const isDark = theme === "dark"; const angle = direction ?? 0; const cardinal = directionName(direction); const degrees = direction !== null ? Math.round(direction) : null; const previousDirection = history.length > 0 ? directionName(history[history.length - 1].value) : "--"; const currentDirection = directionName(direction); const directionTrend = previousDirection === currentDirection ? currentDirection : `${previousDirection} → ${currentDirection}`; const maxGust = windHistory.length > 0 ? Math.max(...windHistory.map((point) => point.value)) : null; const consistency = windConsistency(history); return (

Direção do vento

{Array.from({ length: 48 }).map((_, index) => { const major = index % 6 === 0; return ( ); })}
{cardinal} {degrees ?? "--"}°
); } function windConsistency( history: Array<{ timestamp: string; value: number }> ) { if (history.length < 2) return "--"; let totalVariation = 0; for (let i = 1; i < history.length; i++) { const previous = history[i - 1].value; const current = history[i].value; let delta = Math.abs(current - previous); if (delta > 180) { delta = 360 - delta; } totalVariation += delta; } const averageVariation = totalVariation / (history.length - 1); if (averageVariation <= 5) return "Muito estável"; if (averageVariation <= 15) return "Estável"; if (averageVariation <= 35) return "Variável"; return "Instável"; } function RealtimeChartPanel({ theme, onOpenMeteoCharts, series, historyLoading, }: { theme: "dark" | "light"; onOpenMeteoCharts: () => void; series: ChartSeries[]; historyLoading: boolean; hours: number; }) { const [mode, setMode] = useState("line"); const [timeRange, setTimeRange] = useState("6h"); const [interval, setInterval] = useState("5m"); const [visibleKeys, setVisibleKeys] = useState([ HISTORY_KEYS.temperature, HISTORY_KEYS.humidity, ]); const chart: WorkspaceChartConfig = { id: "meteo-realtime", title: "Gráficos em tempo real", subtitle: "Leituras históricas da estação meteorológica", icon: Activity, status: "online", sourceLabel: "Estação", mode, timeRange, interval, variables: series.map((item) => ({ key: item.key, label: item.shortTitle, unit: item.unit, color: chartColor(item.accent), visible: visibleKeys.includes(item.key), data: item.values.map((point) => ({ timestamp: point.timestamp, value: point.value, })), })), }; function handleVariableToggle(variableKey: string) { const key = variableKey as HistoryKey; setVisibleKeys((current) => { if (current.includes(key)) { return current.length <= 1 ? current : current.filter((item) => item !== key); } return [...current, key]; }); } return (
); } function chartColor(accent: Accent) { switch (accent) { case "amber": return "#facc15"; case "blue": return "#38bdf8"; case "cyan": return "#67e8f9"; case "emerald": return "#34d399"; case "violet": return "#a78bfa"; } } function CompactMeteoChart({ children, }: { children: React.ReactNode; }) { return (
{children}
); } function HeroMetric({ title, value, helper, icon: Icon, }: { title: string; value: string; helper: string; icon: LucideIcon; }) { return (
{title}

{value}

{helper}

); } function SummaryTile({ theme, icon: Icon, accent, label, value, badge, }: { theme: "dark" | "light"; icon: LucideIcon; accent: Accent; label: string; value: string; badge: string; }) { const isDark = theme === "dark"; const colors = accentColors(accent); return (

{label}

{value}

{badge}
); } function SmallStat({ theme, label, value, }: { theme: "dark" | "light"; label: string; value: string; }) { const isDark = theme === "dark"; return (

{label}

{value}

); } function CompassLabel({ label, className, isDark, }: { label: string; className: string; isDark: boolean; }) { return ( {label} ); } function PanelState({ theme, children, }: { theme: "dark" | "light"; children: string; }) { const isDark = theme === "dark"; return (
{children}
); } function HeroState({ children }: { children: string }) { return (
{children}
); } function panelClass(isDark: boolean) { return isDark ? `${RADIUS} flex h-full flex-col border border-white/10 bg-[#071421] p-3 shadow-[0_14px_34px_rgba(0,0,0,0.22)]` : `${RADIUS} flex h-full flex-col border border-slate-200 bg-white p-3 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`; } function panelTitleClass(isDark: boolean) { return isDark ? "text-base font-black text-slate-100" : "text-base font-black text-slate-950"; } function calculateDewPoint( temperature: number | null, humidity: number | null, ) { if (temperature === null || humidity === null || humidity <= 0) return null; return temperature - (100 - humidity) / 5; } function historianSeries(points: HistorianPoint[] | undefined) { return (points ?? []) .filter( (point): point is HistorianPoint & { numericValue: number } => point.numericValue !== null, ) .map((point) => ({ timestamp: point.timestamp, value: point.numericValue, })); } function weatherBackgroundForCondition(condition: string | null | undefined) { const normalized = normalizeWeatherCondition(condition); if (!normalized) return sunnyBackground; if ( normalized.includes("thunder") || normalized.includes("trovoada") || normalized.includes("relampago") || normalized.includes("storm") ) { return thunderstormBackground; } if ( normalized.includes("snowstorm") || normalized.includes("blizzard") || normalized.includes("heavy snow") || normalized.includes("neve forte") || normalized.includes("tempestade de neve") ) { return snowstormBackground; } if (normalized.includes("snow") || normalized.includes("neve")) { return snowBackground; } if ( normalized.includes("patchy rain") || normalized.includes("rain nearby") || normalized.includes("possibilidade de chuva") || normalized.includes("aguaceiros fracos") ) { return patchyRainBackground; } if ( normalized.includes("rain") || normalized.includes("chuva") || normalized.includes("aguaceiro") || normalized.includes("drizzle") || normalized.includes("chuvisco") ) { return rainyBackground; } if ( normalized.includes("fog") || normalized.includes("mist") || normalized.includes("nevoeiro") || normalized.includes("neblina") ) { return foggyBackground; } if (normalized.includes("overcast") || normalized.includes("encoberto")) { return overcastBackground; } if ( normalized.includes("partly cloudy") || normalized.includes("parcialmente nublado") || normalized.includes("partly") || normalized.includes("parcialmente") ) { return partlyCloudyBackground; } if (normalized.includes("cloudy") || normalized.includes("nublado")) { return cloudyBackground; } return sunnyBackground; } function normalizeWeatherCondition(condition: string | null | undefined) { return (condition ?? "") .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase(); } function formatNumber(value: number | null | undefined, decimals = 0) { if (value === null || value === undefined || Number.isNaN(value)) return "--"; return Number.isInteger(value) || decimals === 0 ? String(Math.round(value)) : value.toFixed(decimals); } 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 weekday(date: string) { return new Date(date) .toLocaleDateString("pt-PT", { weekday: "long" }) .replace(/^./, (letter) => letter.toUpperCase()); } function shortDate(date: string) { return new Date(date) .toLocaleDateString("pt-PT", { day: "2-digit", month: "short" }) .replace(".", ""); } function temperatureBadge(value: number | null) { if (value === null) return "Sem dados"; if (value >= 30) return "Quente"; if (value <= 10) return "Frio"; return "Normal"; } function humidityBadge(value: number | null) { if (value === null) return "Sem dados"; if (value >= 80) return "Alta"; if (value <= 35) return "Baixa"; return "Normal"; } function windBadge(value: number | null) { if (value === null) return "Sem dados"; if (value >= 30) return "Forte"; if (value >= 10) return "Moderado"; return "Fraco"; } function uvLabel(value: number | null | undefined) { if (value === null || value === undefined) return "--"; if (value >= 11) return "Extremo"; if (value >= 8) return "Muito elevado"; if (value >= 6) return "Elevado"; if (value >= 3) return "Moderado"; return "Baixo"; } function accentColors(accent: Accent) { switch (accent) { case "amber": return { text: "text-amber-300", stroke: "stroke-amber-300", fill: "fill-amber-300", bg: "bg-amber-300", }; case "blue": return { text: "text-sky-400", stroke: "stroke-sky-400", fill: "fill-sky-400", bg: "bg-sky-400", }; case "cyan": return { text: "text-cyan-300", stroke: "stroke-cyan-300", fill: "fill-cyan-300", bg: "bg-cyan-300", }; case "emerald": return { text: "text-emerald-400", stroke: "stroke-emerald-400", fill: "fill-emerald-400", bg: "bg-emerald-400", }; case "violet": return { text: "text-violet-400", stroke: "stroke-violet-400", fill: "fill-violet-400", bg: "bg-violet-400", }; } } function displayUnit(unit: string | null | undefined) { if (!unit) return ""; const normalized = unit.trim(); if (normalized === "C" || normalized === "ºC") return "°C"; if (normalized === "w/m2" || normalized === "W/m2") return "W/m²"; return normalized; }