Adds legacy meteo modal
This commit is contained in:
@@ -27,6 +27,7 @@ export type AppPage =
|
||||
| "irrigationFilters"
|
||||
| "irrigationConsumption"
|
||||
| "irrigationDrainage"
|
||||
| "meteoCharts"
|
||||
|
||||
function App() {
|
||||
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -25,6 +25,7 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { displayUnit } from "../../shared/utils/displayUnit";
|
||||
export type WorkspaceChartMode = "line" | "area" | "bar";
|
||||
|
||||
export type WorkspaceChartTimeRange = "15m" | "1h" | "6h" | "24h" | "7d" | "30d";
|
||||
@@ -587,7 +588,7 @@ export function WorkspaceChart({
|
||||
style={{ backgroundColor: variable.color }}
|
||||
/>
|
||||
{variable.label}
|
||||
{variable.unit && ` (${variable.unit})`}
|
||||
{variable.unit && ` (${displayUnit(variable.unit)})`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -596,15 +597,15 @@ export function WorkspaceChart({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-4 text-[11px] text-[#8290A6]">
|
||||
<span>
|
||||
Média:{" "}
|
||||
<b>{formatValue(stats.average, primaryVariable.unit)}</b>
|
||||
<b>{formatValue(stats.average, displayUnit(primaryVariable.unit))}</b>
|
||||
</span>
|
||||
<span>
|
||||
Máx:{" "}
|
||||
<b>{formatValue(stats.max, primaryVariable.unit)}</b>
|
||||
<b>{formatValue(stats.max, displayUnit(primaryVariable.unit))}</b>
|
||||
</span>
|
||||
<span>
|
||||
Mín:{" "}
|
||||
<b>{formatValue(stats.min, primaryVariable.unit)}</b>
|
||||
<b>{formatValue(stats.min, displayUnit(primaryVariable.unit))}</b>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -634,7 +635,7 @@ export function WorkspaceChart({
|
||||
<Bar
|
||||
key={variable.key}
|
||||
name={variable.label}
|
||||
unit={variable.unit}
|
||||
unit={displayUnit(variable.unit)}
|
||||
dataKey={variable.key}
|
||||
yAxisId={getYAxisId(variable, yAxes)}
|
||||
fill={variable.color}
|
||||
@@ -660,7 +661,7 @@ export function WorkspaceChart({
|
||||
<Area
|
||||
key={variable.key}
|
||||
name={variable.label}
|
||||
unit={variable.unit}
|
||||
unit={displayUnit(variable.unit)}
|
||||
type="monotone"
|
||||
dataKey={variable.key}
|
||||
yAxisId={getYAxisId(variable, yAxes)}
|
||||
@@ -691,7 +692,7 @@ export function WorkspaceChart({
|
||||
<Line
|
||||
key={variable.key}
|
||||
name={variable.label}
|
||||
unit={variable.unit}
|
||||
unit={displayUnit(variable.unit)}
|
||||
type="monotone"
|
||||
dataKey={variable.key}
|
||||
yAxisId={getYAxisId(variable, yAxes)}
|
||||
@@ -953,15 +954,20 @@ function windowButtonClass(isDark: boolean) {
|
||||
|
||||
function formatValue(value: number | null, unit?: string) {
|
||||
if (value === null || Number.isNaN(value)) return "--";
|
||||
return `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`;
|
||||
|
||||
const normalizedUnit = displayUnit(unit);
|
||||
|
||||
return `${value.toFixed(1)}${normalizedUnit ? ` ${normalizedUnit}` : ""}`;
|
||||
}
|
||||
|
||||
function formatSignedValue(value: number | null, unit?: string) {
|
||||
if (value === null || Number.isNaN(value)) return "--";
|
||||
const prefix = value >= 0 ? "+" : "";
|
||||
return `${prefix}${value.toFixed(1)}${unit ? ` ${unit}` : ""}`;
|
||||
}
|
||||
|
||||
const prefix = value >= 0 ? "+" : "";
|
||||
const normalizedUnit = displayUnit(unit);
|
||||
|
||||
return `${prefix}${value.toFixed(1)}${normalizedUnit ? ` ${normalizedUnit}` : ""}`;
|
||||
}
|
||||
function formatRangeLabel(range: WorkspaceChartTimeRange) {
|
||||
return range.toUpperCase();
|
||||
}
|
||||
@@ -1090,6 +1096,7 @@ function axisIdForUnit(unit: string): string {
|
||||
}
|
||||
|
||||
function normalizeUnit(unit?: string): string {
|
||||
return unit?.trim() ?? "";
|
||||
return displayUnit(unit);
|
||||
}
|
||||
|
||||
export default WorkspaceChart;
|
||||
@@ -300,7 +300,9 @@ function pageTitle(page: AppPage | null) {
|
||||
return "Gráficos Gerais";
|
||||
|
||||
case "meteo":
|
||||
return "Meteorologia";
|
||||
return "Previsões";
|
||||
case "meteoCharts":
|
||||
return "Gráficos";
|
||||
|
||||
case "synoptic":
|
||||
return "Sinótico";
|
||||
|
||||
@@ -30,31 +30,36 @@ type SidebarProps = {
|
||||
|
||||
const RADIUS = "rounded-[6px]";
|
||||
|
||||
const navigationItems: {
|
||||
const meteoItems: {
|
||||
label: string;
|
||||
page: AppPage;
|
||||
icon: React.ElementType;
|
||||
}[] = [
|
||||
{ label: "Painel Principal", page: "dashboard", icon: Home },
|
||||
{ label: "Meteorologia", page: "meteo", icon: CloudSun },
|
||||
{ label: "Gráficos Gerais", page: "maincharts", icon: BarChart3 },
|
||||
{ label: "Sinótico", page: "synoptic" , icon: MonitorDot }
|
||||
|
||||
{ label: "Previsões", page: "meteo", icon: CloudSun },
|
||||
{ label: "Gráficos", page: "meteoCharts", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const climateItems = [
|
||||
{ label: "Iluminação", icon: Lightbulb },
|
||||
{ label: "Ventilação", icon: Wind },
|
||||
{ label: "Gráficos", icon: BarChart3 },
|
||||
];
|
||||
const climateItems: {
|
||||
label: string;
|
||||
page?: AppPage;
|
||||
icon: React.ElementType;
|
||||
}[] = [
|
||||
{ label: "Iluminação", icon: Lightbulb },
|
||||
{ label: "Ventilação", icon: Wind },
|
||||
{ label: "Gráficos", page: "climateCharts", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const irrigationItems = [
|
||||
{ label: "Regas", icon: Droplet },
|
||||
{ label: "Filtros de Rega", icon: Filter },
|
||||
{ label: "Consumos", icon: Gauge },
|
||||
{ label: "Drenagem", icon: Waves },
|
||||
{ label: "Gráficos", icon: BarChart3 },
|
||||
];
|
||||
const irrigationItems: {
|
||||
label: string;
|
||||
page?: AppPage;
|
||||
icon: React.ElementType;
|
||||
}[] = [
|
||||
{ label: "Regas", icon: Droplet },
|
||||
{ label: "Filtros de Rega", icon: Filter },
|
||||
{ label: "Consumos", icon: Gauge },
|
||||
{ label: "Drenagem", icon: Waves },
|
||||
{ label: "Gráficos", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const utilityItems: {
|
||||
label: string;
|
||||
@@ -74,30 +79,40 @@ export function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const [meteoOpen, setMeteoOpen] = useState(true);
|
||||
const [climateOpen, setClimateOpen] = useState(false);
|
||||
const [irrigationOpen, setIrrigationOpen] = useState(false);
|
||||
const [activeTreeItem, setActiveTreeItem] = useState<string | null>(null);
|
||||
|
||||
const handleTreeClick = (key: string) => {
|
||||
const handleTreeClick = (key: string, page?: AppPage) => {
|
||||
setActiveTreeItem(key);
|
||||
|
||||
if (key === "climate:Gráficos") {
|
||||
onNavigate("climateCharts");
|
||||
if (page) {
|
||||
onNavigate(page);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTreeToggle = (section: "climate" | "irrigation") => {
|
||||
const handleTreeToggle = (section: "meteo" | "climate" | "irrigation") => {
|
||||
if (collapsed) {
|
||||
onToggleCollapsed();
|
||||
}
|
||||
|
||||
if (section === "meteo") {
|
||||
setMeteoOpen((current) => !current);
|
||||
setClimateOpen(false);
|
||||
setIrrigationOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (section === "climate") {
|
||||
setClimateOpen((current) => !current);
|
||||
setMeteoOpen(false);
|
||||
setIrrigationOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIrrigationOpen((current) => !current);
|
||||
setMeteoOpen(false);
|
||||
setClimateOpen(false);
|
||||
};
|
||||
|
||||
@@ -105,10 +120,8 @@ export function Sidebar({
|
||||
<aside
|
||||
className={
|
||||
isDark
|
||||
? `${collapsed ? "w-20" : "w-[290px]"
|
||||
} flex h-full flex-col border-r border-[#263247] bg-[#0B1220] px-4 py-5 text-slate-100 transition-all duration-200`
|
||||
: `${collapsed ? "w-20" : "w-[290px]"
|
||||
} flex h-full flex-col border-r border-[#D7DEE8] bg-[#F3F6FA] px-4 py-5 text-[#0F172A] transition-all duration-200`
|
||||
? `${collapsed ? "w-20" : "w-[290px]"} flex h-full flex-col border-r border-[#263247] bg-[#0B1220] px-4 py-5 text-slate-100 transition-all duration-200`
|
||||
: `${collapsed ? "w-20" : "w-[290px]"} flex h-full flex-col border-r border-[#D7DEE8] bg-[#F3F6FA] px-4 py-5 text-[#0F172A] transition-all duration-200`
|
||||
}
|
||||
>
|
||||
<div
|
||||
@@ -152,27 +165,61 @@ export function Sidebar({
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1.5">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = activePage === item.page && activeTreeItem === null;
|
||||
<NavItem
|
||||
theme={theme}
|
||||
collapsed={collapsed}
|
||||
label="Painel Principal"
|
||||
page="dashboard"
|
||||
icon={Home}
|
||||
activePage={activePage}
|
||||
activeTreeItem={activeTreeItem}
|
||||
onNavigate={(page) => {
|
||||
setActiveTreeItem(null);
|
||||
onNavigate(page);
|
||||
}}
|
||||
/>
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTreeItem(null);
|
||||
onNavigate(item.page);
|
||||
}}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={navButtonClass(isDark, active, collapsed)}
|
||||
>
|
||||
{active && <ActiveIndicator isDark={isDark} />}
|
||||
<Icon className={navIconClass(isDark, active)} />
|
||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<TreeSection
|
||||
theme={theme}
|
||||
collapsed={collapsed}
|
||||
label="Meteorologia"
|
||||
icon={CloudSun}
|
||||
open={meteoOpen}
|
||||
onToggle={() => handleTreeToggle("meteo")}
|
||||
items={meteoItems}
|
||||
sectionKey="meteo"
|
||||
activeTreeItem={activeTreeItem}
|
||||
activePage={activePage}
|
||||
onItemClick={handleTreeClick}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
theme={theme}
|
||||
collapsed={collapsed}
|
||||
label="Gráficos Gerais"
|
||||
page="maincharts"
|
||||
icon={BarChart3}
|
||||
activePage={activePage}
|
||||
activeTreeItem={activeTreeItem}
|
||||
onNavigate={(page) => {
|
||||
setActiveTreeItem(null);
|
||||
onNavigate(page);
|
||||
}}
|
||||
/>
|
||||
|
||||
<NavItem
|
||||
theme={theme}
|
||||
collapsed={collapsed}
|
||||
label="Sinótico"
|
||||
page="synoptic"
|
||||
icon={MonitorDot}
|
||||
activePage={activePage}
|
||||
activeTreeItem={activeTreeItem}
|
||||
onNavigate={(page) => {
|
||||
setActiveTreeItem(null);
|
||||
onNavigate(page);
|
||||
}}
|
||||
/>
|
||||
|
||||
<SectionLabel collapsed={collapsed} label="Operação" />
|
||||
|
||||
@@ -186,6 +233,7 @@ export function Sidebar({
|
||||
items={climateItems}
|
||||
sectionKey="climate"
|
||||
activeTreeItem={activeTreeItem}
|
||||
activePage={activePage}
|
||||
onItemClick={handleTreeClick}
|
||||
/>
|
||||
|
||||
@@ -199,6 +247,7 @@ export function Sidebar({
|
||||
items={irrigationItems}
|
||||
sectionKey="irrigation"
|
||||
activeTreeItem={activeTreeItem}
|
||||
activePage={activePage}
|
||||
onItemClick={handleTreeClick}
|
||||
/>
|
||||
|
||||
@@ -259,6 +308,7 @@ function TreeSection({
|
||||
items,
|
||||
sectionKey,
|
||||
activeTreeItem,
|
||||
activePage,
|
||||
onItemClick,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
@@ -267,15 +317,18 @@ function TreeSection({
|
||||
icon: React.ElementType;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
items: { label: string; icon: React.ElementType }[];
|
||||
items: { label: string; page?: AppPage; icon: React.ElementType }[];
|
||||
sectionKey: string;
|
||||
activeTreeItem: string | null;
|
||||
onItemClick: (key: string) => void;
|
||||
activePage: AppPage;
|
||||
onItemClick: (key: string, page?: AppPage) => void;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
const hasActiveChild = items.some(
|
||||
(item) => activeTreeItem === `${sectionKey}:${item.label}`,
|
||||
);
|
||||
const hasActiveChild = items.some((item) => {
|
||||
const key = `${sectionKey}:${item.label}`;
|
||||
|
||||
return activeTreeItem === key || Boolean(item.page && activePage === item.page);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -311,13 +364,14 @@ function TreeSection({
|
||||
{items.map((item) => {
|
||||
const SubIcon = item.icon;
|
||||
const key = `${sectionKey}:${item.label}`;
|
||||
const active = activeTreeItem === key;
|
||||
const active =
|
||||
activeTreeItem === key || Boolean(item.page && activePage === item.page);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => onItemClick(key)}
|
||||
onClick={() => onItemClick(key, item.page)}
|
||||
className={
|
||||
active
|
||||
? isDark
|
||||
@@ -405,3 +459,39 @@ function navIconClass(isDark: boolean, active: boolean) {
|
||||
? "h-5 w-5 shrink-0 text-[#6F819B]"
|
||||
: "h-5 w-5 shrink-0 text-slate-500";
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
theme,
|
||||
collapsed,
|
||||
label,
|
||||
page,
|
||||
icon: Icon,
|
||||
activePage,
|
||||
activeTreeItem,
|
||||
onNavigate,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
collapsed: boolean;
|
||||
label: string;
|
||||
page: AppPage;
|
||||
icon: React.ElementType;
|
||||
activePage: AppPage;
|
||||
activeTreeItem: string | null;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
const active = activePage === page && activeTreeItem === null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(page)}
|
||||
title={collapsed ? label : undefined}
|
||||
className={navButtonClass(isDark, active, collapsed)}
|
||||
>
|
||||
{active && <ActiveIndicator isDark={isDark} />}
|
||||
<Icon className={navIconClass(isDark, active)} />
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -1292,3 +1639,14 @@ function accentColors(accent: Accent) {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export function displayUnit(unit: string | null | undefined) {
|
||||
if (!unit) return "";
|
||||
|
||||
const normalized = unit.trim();
|
||||
|
||||
const lower = normalized.toLowerCase();
|
||||
|
||||
switch (lower) {
|
||||
case "c":
|
||||
case "ºc":
|
||||
case "°c":
|
||||
return "°C";
|
||||
|
||||
case "km/h":
|
||||
case "kmh":
|
||||
case "kph":
|
||||
return "km/h";
|
||||
|
||||
case "w/m2":
|
||||
case "w/m²":
|
||||
case "wm2":
|
||||
return "W/m²";
|
||||
|
||||
case "º":
|
||||
case "°":
|
||||
case "deg":
|
||||
case "degree":
|
||||
case "degrees":
|
||||
return "°";
|
||||
|
||||
case "%":
|
||||
case "percent":
|
||||
case "percentage":
|
||||
return "%";
|
||||
|
||||
case "ppm":
|
||||
return "ppm";
|
||||
|
||||
case "bar":
|
||||
return "bar";
|
||||
|
||||
case "mbar":
|
||||
case "mb":
|
||||
case "hpa":
|
||||
return "hPa";
|
||||
|
||||
case "mm":
|
||||
return "mm";
|
||||
|
||||
case "l":
|
||||
case "lt":
|
||||
case "liter":
|
||||
case "litre":
|
||||
case "litros":
|
||||
return "L";
|
||||
|
||||
case "l/h":
|
||||
case "lt/h":
|
||||
return "L/h";
|
||||
|
||||
case "m3":
|
||||
case "m³":
|
||||
return "m³";
|
||||
|
||||
case "m3/h":
|
||||
case "m³/h":
|
||||
return "m³/h";
|
||||
|
||||
case "v":
|
||||
return "V";
|
||||
|
||||
case "a":
|
||||
return "A";
|
||||
|
||||
case "kw":
|
||||
return "kW";
|
||||
|
||||
case "kwh":
|
||||
return "kWh";
|
||||
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user