Adds legacy meteo modal
This commit is contained in:
@@ -69,10 +69,10 @@ export function WeatherForecastCard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{forecast?.current.condition?.icon && (
|
||||
{forecast?.daily?.[0]?.condition?.icon && (
|
||||
<img
|
||||
src={forecast.current.condition.icon}
|
||||
alt={forecast.current.condition.text}
|
||||
src={forecast.daily[0].condition.icon}
|
||||
alt={forecast.daily[0].condition.text}
|
||||
className="h-12 w-12 opacity-90"
|
||||
/>
|
||||
)}
|
||||
@@ -200,14 +200,14 @@ function TodayForecastHero({
|
||||
theme={theme}
|
||||
icon={Wind}
|
||||
label="Vento"
|
||||
value={`${forecast.current.windKph.toFixed(1)} km/h`}
|
||||
value={`${forecast.daily[0].maxWindKph.toFixed(1)} km/h`}
|
||||
/>
|
||||
|
||||
<WeatherMiniStat
|
||||
theme={theme}
|
||||
icon={Droplets}
|
||||
label="Humidade"
|
||||
value={`${forecast.current.humidity}%`}
|
||||
value={`${forecast.daily[0].averageHumidity}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type MeteoSensorSet = {
|
||||
windSpeed?: ModuleSensorResponse;
|
||||
radiation?: ModuleSensorResponse;
|
||||
rain?: ModuleSensorResponse;
|
||||
co2?: ModuleSensorResponse;
|
||||
};
|
||||
|
||||
export function selectMeteoSensors(
|
||||
@@ -46,6 +47,11 @@ export function selectMeteoSensors(
|
||||
"chuva",
|
||||
"rain",
|
||||
]),
|
||||
|
||||
co2: maxNumericMatch(sensors, [
|
||||
"co",
|
||||
"co2",
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ export function useAccumulatedHistory(
|
||||
key: sensorKey,
|
||||
range,
|
||||
});
|
||||
const url = `${BACKEND_URL}/api/historian/accumulated?${params.toString()}`;
|
||||
console.log("I AM HEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEERE");
|
||||
console.log("[AccumulatedHistory URL]", url);
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/historian/accumulated?${params.toString()}`,
|
||||
@@ -47,7 +50,19 @@ export function useAccumulatedHistory(
|
||||
throw new Error("Failed to load accumulated history");
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AccumulatedBucket[];
|
||||
const payload = ((await response.json()) as AccumulatedBucket[]).sort(
|
||||
(a, b) => new Date(a.from).getTime() - new Date(b.from).getTime(),
|
||||
);
|
||||
|
||||
const todayBucket = payload[payload.length - 1] ?? null;
|
||||
|
||||
console.log("[AccumulatedHistory]", {
|
||||
sensorKey,
|
||||
range,
|
||||
buckets: payload,
|
||||
today: todayBucket,
|
||||
});
|
||||
|
||||
setBuckets(payload);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
@@ -4,13 +4,18 @@ import {
|
||||
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";
|
||||
@@ -38,7 +43,8 @@ import WorkspaceChart, {
|
||||
} 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";
|
||||
};
|
||||
@@ -75,16 +81,33 @@ type HistoryKey = (typeof HISTORY_KEYS)[keyof typeof HISTORY_KEYS];
|
||||
export function MeteoPage({ theme }: 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,
|
||||
@@ -118,6 +141,7 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
const currentWindSpeed = numericSensorValue(windSpeed);
|
||||
const currentRadiation = numericSensorValue(radiation);
|
||||
const currentWindDirection = numericSensorValue(windDirection);
|
||||
const currentCo2 = numericSensorValue(co2)
|
||||
const currentDewPoint = calculateDewPoint(
|
||||
currentTemperature,
|
||||
currentHumidity,
|
||||
@@ -185,6 +209,7 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
selectedDayIndex={selectedForecastDayIndex}
|
||||
loading={weatherForecast.loading}
|
||||
error={weatherForecast.error}
|
||||
onOpenWeatherBoard={() => setWeatherBoardOpen(true)}
|
||||
/>
|
||||
|
||||
<ForecastPanel
|
||||
@@ -204,10 +229,10 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
windSpeed={currentWindSpeed}
|
||||
dewPoint={currentDewPoint}
|
||||
radiation={currentRadiation}
|
||||
temperatureUnit={temperature?.unit ?? "°C"}
|
||||
humidityUnit={humidity?.unit ?? "%"}
|
||||
windUnit={windSpeed?.unit ?? "km/h"}
|
||||
radiationUnit={radiation?.unit ?? "W/m²"}
|
||||
temperatureUnit={displayUnit(temperature?.unit ?? "°C")}
|
||||
humidityUnit={displayUnit(humidity?.unit ?? "%")}
|
||||
windUnit={displayUnit(windSpeed?.unit ?? "km/h")}
|
||||
radiationUnit={displayUnit(radiation?.unit ?? "W/m²")}
|
||||
/>
|
||||
|
||||
<WindDirectionPanel
|
||||
@@ -226,6 +251,310 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
||||
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 / 1000).toFixed(2),
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -237,6 +566,7 @@ function WeatherHeroPanel({
|
||||
selectedDayIndex,
|
||||
loading,
|
||||
error,
|
||||
onOpenWeatherBoard,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
forecast: WeatherForecastResponse | null;
|
||||
@@ -244,6 +574,7 @@ function WeatherHeroPanel({
|
||||
selectedDayIndex: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onOpenWeatherBoard: () => void;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
const day = selectedDay ?? forecast?.daily?.[0];
|
||||
@@ -298,10 +629,11 @@ function WeatherHeroPanel({
|
||||
|
||||
<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" />
|
||||
Ver quadro legado
|
||||
Abrir quadro meteorológico
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -733,18 +1065,34 @@ function WindDirectionPanel({
|
||||
<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-[112px] w-[46px] origin-bottom rounded-t-full bg-gradient-to-b from-sky-400 to-blue-700/20"
|
||||
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%, 100% 100%, 50% 82%, 0% 100%)",
|
||||
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-24 w-24 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/10 bg-[#0b1220]" : "absolute left-1/2 top-1/2 h-24 w-24 -translate-x-1/2 -translate-y-1/2 rounded-full border border-slate-200 bg-white"} />
|
||||
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? "absolute left-1/2 top-1/2 h-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-4xl font-black text-slate-100" : "text-4xl font-black text-slate-950"}>
|
||||
<span
|
||||
className={
|
||||
isDark
|
||||
? "text-3xl font-black text-slate-100"
|
||||
: "text-3xl font-black text-slate-950"
|
||||
}
|
||||
>
|
||||
{cardinal}
|
||||
</span>
|
||||
<span className="text-xs font-black text-slate-500">
|
||||
|
||||
<span className="text-[11px] font-black text-slate-500">
|
||||
{degrees ?? "--"}°
|
||||
</span>
|
||||
</div>
|
||||
@@ -1031,7 +1379,6 @@ function SmallStat({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function CompassLabel({
|
||||
label,
|
||||
className,
|
||||
@@ -1291,4 +1638,15 @@ function accentColors(accent: Accent) {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user