1659 lines
57 KiB
TypeScript
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;
|
|
} |