Adds legacy meteo modal

This commit is contained in:
litoral05
2026-05-29 17:08:16 +01:00
parent 3905e2adfe
commit 540e4ed560
10 changed files with 648 additions and 85 deletions
+1
View File
@@ -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

+19 -12
View File
@@ -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;
+3 -1
View File
@@ -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";
+135 -45
View File
@@ -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 = [
const climateItems: {
label: string;
page?: AppPage;
icon: React.ElementType;
}[] = [
{ label: "Iluminação", icon: Lightbulb },
{ label: "Ventilação", icon: Wind },
{ label: "Gráficos", icon: BarChart3 },
];
{ label: "Gráficos", page: "climateCharts", icon: BarChart3 },
];
const irrigationItems = [
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;
return (
<button
key={item.label}
type="button"
onClick={() => {
<NavItem
theme={theme}
collapsed={collapsed}
label="Painel Principal"
page="dashboard"
icon={Home}
activePage={activePage}
activeTreeItem={activeTreeItem}
onNavigate={(page) => {
setActiveTreeItem(null);
onNavigate(item.page);
onNavigate(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;
+371 -13
View File
@@ -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;
}
+84
View File
@@ -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;
}
}