Files
litoral-central-frontend/src/features/meteo/pages/MeteoPage.tsx
T

1659 lines
57 KiB
TypeScript

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 (
<div className="space-y-4 pb-4">
<WeatherHeroPanel
theme={theme}
forecast={forecast}
selectedDay={selectedForecastDay}
selectedDayIndex={selectedForecastDayIndex}
loading={weatherForecast.loading}
error={weatherForecast.error}
onOpenWeatherBoard={() => setWeatherBoardOpen(true)}
/>
<ForecastPanel
theme={theme}
forecast={forecast}
loading={weatherForecast.loading}
error={weatherForecast.error}
selectedIndex={selectedForecastDayIndex}
onSelectDay={setSelectedForecastDayIndex}
/>
<div className="grid items-stretch gap-4 xl:grid-cols-[minmax(330px,0.82fr)_minmax(330px,0.82fr)_minmax(620px,1.55fr)]">
<WeatherSummaryPanel
theme={theme}
temperature={currentTemperature}
humidity={currentHumidity}
windSpeed={currentWindSpeed}
dewPoint={currentDewPoint}
radiation={currentRadiation}
temperatureUnit={displayUnit(temperature?.unit ?? "°C")}
humidityUnit={displayUnit(humidity?.unit ?? "%")}
windUnit={displayUnit(windSpeed?.unit ?? "km/h")}
radiationUnit={displayUnit(radiation?.unit ?? "W/m²")}
/>
<WindDirectionPanel
theme={theme}
direction={currentWindDirection}
history={windDirectionHistory}
windHistory={historianSeries(
pointsByKey[windSpeed?.key ?? ""]
)}
/>
<RealtimeChartPanel
theme={theme}
onOpenMeteoCharts={onOpenMeteoCharts}
series={chartSeries}
historyLoading={historyLoading}
hours={HISTORY_HOURS}
/>
</div>
{weatherBoardOpen && (
<WeatherBoardModal
theme={theme}
temperature={currentTemperature}
humidity={currentHumidity}
windSpeed={currentWindSpeed}
co2={currentCo2}
windDirection={currentWindDirection}
radiation={currentRadiation}
dewPoint={currentDewPoint}
accumulatedRain={accumulatedRainToday}
accumulatedRadiation={accumulatedRadiationToday}
onClose={() => setWeatherBoardOpen(false)}
/>
)}
</div>
);
}
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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-6 backdrop-blur-sm">
<section className="relative aspect-[1449/1085] w-[min(96vw,calc(94vh*1449/1085))] overflow-hidden rounded-[5px] border border-sky-400/20 bg-[#020817] shadow-[0_0_80px_rgba(14,165,233,0.18)]">
<img
src={weatherBoardBackground}
alt=""
className="absolute inset-0 h-full w-full"
/>
<button
type="button"
onClick={onClose}
className="absolute right-[2%] top-[2%] z-30 inline-flex h-10 w-10 items-center justify-center rounded-[5px] border border-white/10 bg-black/30 text-slate-200 transition hover:bg-white/[0.12] hover:text-white"
>
<X className="h-5 w-5" />
</button>
<BoardText className="left-[1.6%] top-[1.2%]" value={new Date().toLocaleDateString("pt-PT")} />
<BoardText className="right-[13%] top-[1.2%]" value={new Date().toLocaleTimeString("pt-PT")} />
<BoardMetric
className="left-[10.5%] top-[13%]"
icon={Droplets}
label="Humidade"
value={formatNumber(humidity, 0)}
unit="%"
color="blue"
/>
<BoardMetric
className="left-[10.5%] top-[28%]"
icon={Gauge}
label="DPV"
value={formatNumber(dewPoint, 1)}
unit="mbar"
color="blue"
/>
<BoardMetric
className="left-[10.5%] top-[43%]"
icon={Activity}
label="Humidade absoluta"
value="13,5"
unit="g/m³"
color="blue"
/>
<BoardMetric
className="left-[10.5%] top-[66%]"
icon={CloudRain}
label="Precipitação hoje"
value={accumulatedRain.value}
unit={accumulatedRain.unit || "mm"}
color="orange"
/>
<BoardMetric
className="left-[10.5%] top-[79%]"
icon={CloudRain}
label="Precipitação instant."
value="0.0"
unit="mm/min"
color="orange"
/>
<BoardMetric
className="left-1/2 top-[4%] w-[240px] -translate-x-1/2 text-center"
icon={Thermometer}
label="Temperatura"
value={formatNumber(temperature, 1)}
unit="°C"
color="yellow"
/>
<BoardMetric
className="left-[74%] top-[13%]"
icon={Wind}
label="Velocidade vento"
value={formatNumber(windSpeed, 0)}
unit="km/h"
color="cyan"
/>
<BoardMetric
className="left-[74%] top-[43%]"
icon={Compass}
label="Direção vento"
value={cardinal}
unit=""
color="white"
/>
<BoardMetric
className="left-[74%] top-[61%]"
icon={Sun}
label="Radiação"
value={formatNumber(radiation, 0)}
unit="W/m²"
color="yellow"
/>
<BoardMetric
className="left-[74%] top-[79%]"
icon={Zap}
label="Acumulada hoje"
value={accumulatedRadiation.value}
unit={accumulatedRadiation.unit}
color="yellow"
/>
<BoardMetric
className="left-1/2 bottom-[12%] w-[220px] -translate-x-1/2 text-center"
icon={Activity}
label="CO₂"
value={formatNumber(co2, 0)}
unit="ppm"
color="muted"
/>
<div
className="absolute z-20 h-[15%] w-[15%] -translate-x-1/2 -translate-y-1/2"
style={{
left: "50.3%",
top: "53.2%",
}}
>
<svg
className="absolute inset-0 h-full w-full overflow-visible"
viewBox="0 0 100 100"
>
<g
style={{
transformOrigin: "50px 50px",
transform: `rotate(${angle}deg)`,
}}
>
{/* Needle pointing up (north = 0°) */}
<polygon
points="50,2 44,50 50,56 56,50"
fill="white"
filter="drop-shadow(0 0 8px rgba(255,255,255,0.85))"
/>
{/* Tail */}
<polygon
points="50,98 44,50 50,56 56,50"
fill="rgba(255, 255, 255, 0)"
/>
</g>
</svg>
{/* Center dot */}
<div className="absolute left-1/2 top-1/2 z-[12] h-7 w-7 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow-[0_0_18px_rgba(255,255,255,0.95)]" />
{/* Degree label */}
<div className="absolute left-1/2 top-[6%] z-20 -translate-x-1/2 rounded bg-black/85 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white shadow-[0_0_10px_rgba(0,0,0,0.55)]">
{Math.round(windDirection ?? 0)}°
</div>
</div>
</section >
</div >
);
}
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 (
<div className={`absolute z-20 text-[1.55vw] font-medium text-white ${className}`}>
{value}
</div>
);
}
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 (
<div className={`absolute z-20 w-[220px] ${className}`}>
<div
className={[
"flex items-center gap-2 text-white",
className.includes("text-center") ? "justify-center" : "",
].join(" ")}
>
<Icon className="h-[clamp(17px,1.45vw,24px)] w-[clamp(17px,1.45vw,24px)] opacity-90" />
<p className="text-[clamp(12px,0.95vw,15px)] font-black">
{label}
</p>
</div>
<p
className={[
"mt-3 text-[clamp(22px,1.85vw,30px)] font-black leading-none",
valueColor,
className.includes("text-center") ? "text-center" : "",
].join(" ")}
>
{value}
{unit && (
<span className="ml-1 text-[clamp(10px,0.78vw,13px)] font-bold">
({unit})
</span>
)}
</p>
</div>
);
}
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 <HeroState>Sem previsão disponível.</HeroState>;
}
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 (
<section
className={
isDark
? `${RADIUS} relative overflow-hidden border border-sky-400/15 bg-[#071421] shadow-[0_18px_46px_rgba(0,0,0,0.24)]`
: `${RADIUS} relative overflow-hidden border border-slate-200 bg-white shadow-[0_16px_38px_rgba(15,23,42,0.08)]`
}
>
<img
src={heroBackground}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-75"
style={{ objectPosition: "center 20%" }}
/>
<div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(4,13,24,0.98)_0%,rgba(5,17,31,0.94)_32%,rgba(7,20,33,0.72)_58%,rgba(7,20,33,0.42)_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_82%_18%,rgba(251,191,36,0.18),transparent_24%)]" />
<div className="relative p-5 lg:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-xl font-black tracking-[-0.04em] text-white">
{location}
</h2>
<p className="mt-1 text-sm font-medium text-slate-300">
{selectedDayIndex === 0
? "Condições de hoje"
: `Previsão para ${heroDateLabel}`}
</p>
</div>
<button
type="button"
onClick={onOpenWeatherBoard}
className="inline-flex items-center gap-2 rounded-[5px] border border-sky-400/25 bg-sky-400/10 px-3 py-2 text-sm font-black text-sky-200 transition hover:bg-sky-400/15"
>
<Activity className="h-4 w-4" />
Abrir quadro meteorológico
</button>
</div>
{loading ? (
<HeroState>A carregar meteorologia...</HeroState>
) : error ? (
<HeroState>{error}</HeroState>
) : (
<div className="mt-7 grid gap-5 xl:grid-cols-[260px_1fr]">
<div className="flex items-center gap-5">
{day.condition?.icon ? (
<img
src={day.condition.icon}
alt={day.condition.text}
className="h-20 w-20 shrink-0 drop-shadow-[0_0_22px_rgba(250,204,21,0.35)]"
/>
) : (
<Sun className="h-20 w-20 shrink-0 fill-yellow-300 text-yellow-300 drop-shadow-[0_0_22px_rgba(250,204,21,0.35)]" />
)}
<div>
<div className="text-[48px] font-black leading-none tracking-[-0.07em] text-white">
{formatNumber(heroTemperature, 1)}°
</div>
<p className="mt-2 text-sm font-black text-white">
{conditionText}
</p>
<p className="mt-1 text-xs font-semibold text-slate-400">
{heroMinTemperature !== null
? `Mínima ${formatNumber(heroMinTemperature, 0)}°`
: "Sem mínima disponível"}
</p>
</div>
</div>
<div className="grid gap-0 divide-y divide-white/10 rounded-[5px] border border-white/0 sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-5">
<HeroMetric
title="Chuva"
value={`${day.dailyRainChance ?? "--"}%`}
helper={`${formatNumber(day.totalPrecipitationMm, 1)} mm previstos`}
icon={CloudRain}
/>
<HeroMetric
title="UV Index"
value={formatNumber(day.uv, 0)}
helper={uvLabel(day.uv)}
icon={Sun}
/>
<HeroMetric
title="Vento"
value={`${formatNumber(day.averageWindKph, 1)} km/h`}
helper={
day.averageWindDirection
? `Direção média · ${day.averageWindDirection}`
: "--"
}
icon={Wind}
/>
<HeroMetric
title="Humidade"
value={`${formatNumber(day.averageHumidity, 0)}%`}
helper="Média diária"
icon={Droplets}
/>
<HeroMetric
title="Visibilidade"
value={`${formatNumber(day.averageVisibilityKm, 1)} km`}
helper="Média diária"
icon={Eye}
/>
</div>
</div>
)}
</div>
</section>
);
}
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 (
<section className={panelClass(isDark)}>
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className={panelTitleClass(isDark)}>Previsão diária</h2>
<p className="mt-1 text-sm text-slate-400">Próximos 7 dias</p>
</div>
{selectedDay && (
<div className="hidden text-right lg:block">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-slate-500">
Dia selecionado
</p>
<p className="mt-1 text-sm font-black text-slate-200">
{weekday(selectedDay.date)} · {shortDate(selectedDay.date)}
</p>
</div>
)}
</div>
{loading ? (
<PanelState theme={theme}>A carregar previsão...</PanelState>
) : error ? (
<PanelState theme={theme}>{error}</PanelState>
) : !days.length ? (
<PanelState theme={theme}>Sem previsão diária disponível.</PanelState>
) : (
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-7">
{days.map((day, index) => (
<ForecastDayCard
key={day.date}
theme={theme}
day={day}
index={index}
active={index === selectedIndex}
onClick={() => onSelectDay(index)}
/>
))}
</div>
)}
</section>
);
}
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 (
<button
type="button"
onClick={onClick}
className={[
RADIUS,
"group relative min-h-[110px] overflow-hidden px-3.5 py-3 text-left outline-none transition-all duration-300 focus-visible:ring-1 focus-visible:ring-sky-300/45",
active
? isDark
? "bg-white/[0.04]"
: "bg-slate-100"
: isDark
? "bg-white/[0.015] hover:bg-white/[0.035]"
: "bg-slate-100/60 hover:bg-slate-100",
].join(" ")}
style={{
backgroundImage: `
linear-gradient(
180deg,
rgba(4,13,24,${active ? "0.78" : "0.86"}),
rgba(4,13,24,${active ? "0.95" : "0.985"})
),
url(${background})
`,
backgroundSize: "cover",
backgroundPosition: "center 24%",
}}
>
<span
className={[
"pointer-events-none absolute inset-0 transition-colors duration-300",
active
? "bg-white/[0.015]"
: "bg-[#071421]/35 group-hover:bg-[#071421]/24",
].join(" ")}
/>
<span
className={[
"pointer-events-none absolute inset-0 backdrop-blur-[2px] transition-opacity duration-300",
active ? "opacity-45" : "opacity-75 group-hover:opacity-60",
].join(" ")}
/>
<span className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/14 to-transparent" />
<span
className={[
"pointer-events-none absolute inset-0 rounded-[inherit] transition-opacity duration-300",
active
? "shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08),inset_0_1px_0_rgba(255,255,255,0.10)]"
: "shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035),inset_0_1px_0_rgba(255,255,255,0.06)]",
].join(" ")}
/>
{active && (
<span className="pointer-events-none absolute left-4 right-4 top-0 h-px bg-white/55" />
)}
<span className="relative z-10 flex h-full flex-col justify-between">
<span className="flex items-start justify-between gap-3">
<span>
<span className="block text-sm font-black text-white">
{label}
</span>
<span className="mt-1 block text-xs font-semibold text-slate-300">
{shortDate(day.date)}
</span>
</span>
{day.condition?.icon ? (
<img
src={day.condition.icon}
alt={day.condition.text}
className={[
"h-9 w-9 shrink-0 transition duration-300",
active
? "opacity-95 drop-shadow-[0_0_10px_rgba(250,204,21,0.20)]"
: "opacity-75 drop-shadow-[0_0_8px_rgba(250,204,21,0.14)] group-hover:opacity-90",
].join(" ")}
/>
) : (
<Cloud className="h-9 w-9 shrink-0 text-slate-300" />
)}
</span>
<span className="flex items-end justify-between gap-3">
<span className="flex items-end gap-2">
<span className="text-2xl font-black leading-none tracking-[-0.06em] text-white">
{Math.round(day.maxTemperatureC)}°
</span>
<span className="mb-0.5 text-sm font-black text-slate-400">
{Math.round(day.minTemperatureC)}°
</span>
</span>
<span
className={[
"rounded-full px-2 py-1 text-[10px] font-black uppercase tracking-[0.08em] backdrop-blur-sm transition",
active
? "border border-white/10 bg-white/[0.065] text-white"
: "border border-white/10 bg-white/[0.045] text-slate-400 group-hover:text-slate-300",
].join(" ")}
>
UV {formatNumber(day.uv, 0)}
</span>
</span>
</span>
</button>
);
}
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 (
<section className={panelClass(isDark)}>
<h2 className={panelTitleClass(isDark)}>Resumo meteorológico</h2>
<div className="mt-3 grid flex-1 content-start gap-2">
<SummaryTile
theme={theme}
icon={Thermometer}
accent="amber"
label="Temperatura"
value={`${formatNumber(temperature, 1)}${temperatureUnit}`}
badge={temperatureBadge(temperature)}
/>
<SummaryTile
theme={theme}
icon={Wind}
accent="cyan"
label="Vento"
value={`${formatNumber(windSpeed, 0)} ${windUnit}`}
badge={windBadge(windSpeed)}
/>
<SummaryTile
theme={theme}
icon={Droplets}
accent="blue"
label="Humidade"
value={`${formatNumber(humidity, 0)}${humidityUnit}`}
badge={humidityBadge(humidity)}
/>
<SummaryTile
theme={theme}
icon={Activity}
accent="emerald"
label="Ponto de orvalho"
value={`${formatNumber(dewPoint, 1)}°C`}
badge="Elevado"
/>
<SummaryTile
theme={theme}
icon={Zap}
accent="violet"
label="Radiação"
value={`${formatNumber(radiation, 0)} ${radiationUnit}`}
badge={radiationBadge(radiation)}
/>
</div>
</section>
);
}
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 (
<section className={panelClass(isDark)}>
<h2 className={panelTitleClass(isDark)}>Direção do vento</h2>
<div className="mt-2 flex flex-1 items-center justify-center">
<div className="relative h-[250px] w-[250px]">
<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-[74px] rounded-full border border-white/10 bg-white/[0.02]" : "absolute inset-[74px] rounded-full border border-slate-200 bg-white"} />
{Array.from({ length: 48 }).map((_, index) => {
const major = index % 6 === 0;
return (
<span
key={index}
className={`absolute left-1/2 top-1/2 w-px origin-[50%_116px] ${major ? "h-3" : "h-1.5"} ${isDark ? "bg-slate-600/60" : "bg-slate-300"}`}
style={{
transform: `translate(-50%, -116px) rotate(${index * 7.5}deg)`,
}}
/>
);
})}
<CompassLabel label="N" isDark={isDark} className="left-1/2 top-[14px] -translate-x-1/2" />
<CompassLabel label="S" isDark={isDark} className="bottom-[14px] left-1/2 -translate-x-1/2" />
<CompassLabel label="W" isDark={isDark} className="left-[16px] top-1/2 -translate-y-1/2" />
<CompassLabel label="E" isDark={isDark} className="right-[16px] top-1/2 -translate-y-1/2" />
<CompassLabel label="NW" isDark={isDark} className="left-[50px] top-[50px]" />
<CompassLabel label="NE" isDark={isDark} className="right-[50px] top-[50px]" />
<CompassLabel label="SW" isDark={isDark} className="bottom-[50px] left-[50px]" />
<CompassLabel label="SE" isDark={isDark} className="bottom-[50px] right-[50px]" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative h-24 w-24">
<div
className="absolute left-1/2 top-1/2 h-[105px] w-[34px] origin-bottom bg-gradient-to-b from-sky-400 to-blue-700/20"
style={{
clipPath:
"polygon(50% 0%, 85% 70%, 60% 70%, 60% 100%, 40% 100%, 40% 70%, 15% 70%)",
transform: `translate(-50%, -100%) rotate(${angle}deg)`,
}}
/>
<div
className={
isDark
? "absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/10 bg-[#0b1220]"
: "absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 bg-white"
}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span
className={
isDark
? "text-3xl font-black text-slate-100"
: "text-3xl font-black text-slate-950"
}
>
{cardinal}
</span>
<span className="text-[11px] font-black text-slate-500">
{degrees ?? "--"}°
</span>
</div>
</div>
</div>
</div>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<SmallStat
theme={theme}
label="Rajada máx."
value={maxGust !== null ? `${Math.round(maxGust)} km/h` : "--"}
/>
<SmallStat
theme={theme}
label="Direção"
value={directionTrend}
/>
<SmallStat
theme={theme}
label="Constância"
value={consistency}
/>
</div>
</section>
);
}
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<WorkspaceChartMode>("line");
const [timeRange, setTimeRange] = useState<WorkspaceChartTimeRange>("6h");
const [interval, setInterval] = useState<WorkspaceChartInterval>("5m");
const [visibleKeys, setVisibleKeys] = useState<HistoryKey[]>([
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 (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="absolute right-3 top-3 z-20">
<button
type="button"
className="rounded-[5px] border border-sky-400/20 bg-sky-400/10 px-3 py-2 text-xs font-black text-sky-200 transition hover:bg-sky-400/15"
onClick={onOpenMeteoCharts}
>
Gráficos Personalizados
</button>
</div>
<CompactMeteoChart>
<WorkspaceChart
theme={theme}
chart={chart}
loading={historyLoading}
configuredVariableCount={series.length}
onModeChange={setMode}
onVariableToggle={handleVariableToggle}
onTimeRangeChange={setTimeRange}
onIntervalChange={setInterval}
/>
</CompactMeteoChart>
</div>
);
}
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 (
<div className="h-full [&>section]:h-full [&>section>header]:px-4 [&>section>header]:py-3 [&>section>main]:px-3 [&>section>main]:pb-3 [&_main_section>div.relative]:h-[235px] [&_main_section>div.relative]:min-h-[235px]">
{children}
</div>
);
}
function HeroMetric({
title,
value,
helper,
icon: Icon,
}: {
title: string;
value: string;
helper: string;
icon: LucideIcon;
}) {
return (
<div className="px-4 py-3">
<div className="flex items-center gap-2 text-xs font-semibold text-slate-400">
<Icon className="h-3.5 w-3.5" />
{title}
</div>
<p className="mt-3 text-base font-black text-white">{value}</p>
<p className="mt-2 text-xs font-medium text-slate-400">{helper}</p>
</div>
);
}
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 (
<article
className={
isDark
? `${RADIUS} grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-1 border border-white/10 bg-white/[0.03] px-3.5 py-3`
: `${RADIUS} grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-1 border border-slate-200 bg-slate-50 px-3.5 py-3`
}
>
<div className="flex min-w-0 items-center gap-2.5">
<Icon className={`h-5 w-5 shrink-0 ${colors.text}`} />
<p
className={
isDark
? "truncate text-sm font-semibold text-slate-300"
: "truncate text-sm font-semibold text-slate-600"
}
>
{label}
</p>
</div>
<p
className={
isDark
? "text-[22px] font-black leading-none tracking-[-0.04em] text-slate-100"
: "text-[22px] font-black leading-none tracking-[-0.04em] text-slate-950"
}
>
{value}
</p>
<div className="col-span-2 flex items-center gap-2 pl-7">
<span
className={
isDark
? "rounded-[5px] bg-slate-700 px-2 py-1 text-[11px] font-black text-slate-200"
: "rounded-[5px] bg-white px-2 py-1 text-[11px] font-black text-slate-700"
}
>
{badge}
</span>
</div>
</article>
);
}
function SmallStat({
theme,
label,
value,
}: {
theme: "dark" | "light";
label: string;
value: string;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-white/[0.03] px-2 py-2.5 text-center`
: `${RADIUS} border border-slate-200 bg-slate-50 px-2 py-2.5 text-center`
}
>
<p className="text-[10px] font-bold text-slate-500">{label}</p>
<p
className={
isDark
? "mt-1 text-sm font-black text-slate-100"
: "mt-1 text-sm font-black text-slate-950"
}
>
{value}
</p>
</div>
);
}
function CompassLabel({
label,
className,
isDark,
}: {
label: string;
className: string;
isDark: boolean;
}) {
return (
<span
className={`absolute flex h-6 min-w-6 items-center justify-center rounded-full px-1 text-[12px] font-black leading-none ${isDark ? "text-slate-300" : "text-slate-500"
} ${className}`}
>
{label}
</span>
);
}
function PanelState({
theme,
children,
}: {
theme: "dark" | "light";
children: string;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-white/[0.03] p-5 text-sm text-slate-400`
: `${RADIUS} border border-slate-200 bg-slate-50 p-5 text-sm text-slate-500`
}
>
{children}
</div>
);
}
function HeroState({ children }: { children: string }) {
return (
<div className="mt-7 rounded-[5px] border border-white/10 bg-white/[0.03] p-5 text-sm font-semibold text-slate-300">
{children}
</div>
);
}
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;
}