Adds legacy meteo modal
This commit is contained in:
@@ -27,6 +27,7 @@ export type AppPage =
|
|||||||
| "irrigationFilters"
|
| "irrigationFilters"
|
||||||
| "irrigationConsumption"
|
| "irrigationConsumption"
|
||||||
| "irrigationDrainage"
|
| "irrigationDrainage"
|
||||||
|
| "meteoCharts"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -25,6 +25,7 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
|
import { displayUnit } from "../../shared/utils/displayUnit";
|
||||||
export type WorkspaceChartMode = "line" | "area" | "bar";
|
export type WorkspaceChartMode = "line" | "area" | "bar";
|
||||||
|
|
||||||
export type WorkspaceChartTimeRange = "15m" | "1h" | "6h" | "24h" | "7d" | "30d";
|
export type WorkspaceChartTimeRange = "15m" | "1h" | "6h" | "24h" | "7d" | "30d";
|
||||||
@@ -587,7 +588,7 @@ export function WorkspaceChart({
|
|||||||
style={{ backgroundColor: variable.color }}
|
style={{ backgroundColor: variable.color }}
|
||||||
/>
|
/>
|
||||||
{variable.label}
|
{variable.label}
|
||||||
{variable.unit && ` (${variable.unit})`}
|
{variable.unit && ` (${displayUnit(variable.unit)})`}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -596,15 +597,15 @@ export function WorkspaceChart({
|
|||||||
<div className="ml-auto flex shrink-0 items-center gap-4 text-[11px] text-[#8290A6]">
|
<div className="ml-auto flex shrink-0 items-center gap-4 text-[11px] text-[#8290A6]">
|
||||||
<span>
|
<span>
|
||||||
Média:{" "}
|
Média:{" "}
|
||||||
<b>{formatValue(stats.average, primaryVariable.unit)}</b>
|
<b>{formatValue(stats.average, displayUnit(primaryVariable.unit))}</b>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Máx:{" "}
|
Máx:{" "}
|
||||||
<b>{formatValue(stats.max, primaryVariable.unit)}</b>
|
<b>{formatValue(stats.max, displayUnit(primaryVariable.unit))}</b>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Mín:{" "}
|
Mín:{" "}
|
||||||
<b>{formatValue(stats.min, primaryVariable.unit)}</b>
|
<b>{formatValue(stats.min, displayUnit(primaryVariable.unit))}</b>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -634,7 +635,7 @@ export function WorkspaceChart({
|
|||||||
<Bar
|
<Bar
|
||||||
key={variable.key}
|
key={variable.key}
|
||||||
name={variable.label}
|
name={variable.label}
|
||||||
unit={variable.unit}
|
unit={displayUnit(variable.unit)}
|
||||||
dataKey={variable.key}
|
dataKey={variable.key}
|
||||||
yAxisId={getYAxisId(variable, yAxes)}
|
yAxisId={getYAxisId(variable, yAxes)}
|
||||||
fill={variable.color}
|
fill={variable.color}
|
||||||
@@ -660,7 +661,7 @@ export function WorkspaceChart({
|
|||||||
<Area
|
<Area
|
||||||
key={variable.key}
|
key={variable.key}
|
||||||
name={variable.label}
|
name={variable.label}
|
||||||
unit={variable.unit}
|
unit={displayUnit(variable.unit)}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={variable.key}
|
dataKey={variable.key}
|
||||||
yAxisId={getYAxisId(variable, yAxes)}
|
yAxisId={getYAxisId(variable, yAxes)}
|
||||||
@@ -691,7 +692,7 @@ export function WorkspaceChart({
|
|||||||
<Line
|
<Line
|
||||||
key={variable.key}
|
key={variable.key}
|
||||||
name={variable.label}
|
name={variable.label}
|
||||||
unit={variable.unit}
|
unit={displayUnit(variable.unit)}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={variable.key}
|
dataKey={variable.key}
|
||||||
yAxisId={getYAxisId(variable, yAxes)}
|
yAxisId={getYAxisId(variable, yAxes)}
|
||||||
@@ -953,15 +954,20 @@ function windowButtonClass(isDark: boolean) {
|
|||||||
|
|
||||||
function formatValue(value: number | null, unit?: string) {
|
function formatValue(value: number | null, unit?: string) {
|
||||||
if (value === null || Number.isNaN(value)) return "--";
|
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) {
|
function formatSignedValue(value: number | null, unit?: string) {
|
||||||
if (value === null || Number.isNaN(value)) return "--";
|
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) {
|
function formatRangeLabel(range: WorkspaceChartTimeRange) {
|
||||||
return range.toUpperCase();
|
return range.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1096,7 @@ function axisIdForUnit(unit: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUnit(unit?: string): string {
|
function normalizeUnit(unit?: string): string {
|
||||||
return unit?.trim() ?? "";
|
return displayUnit(unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WorkspaceChart;
|
export default WorkspaceChart;
|
||||||
@@ -300,7 +300,9 @@ function pageTitle(page: AppPage | null) {
|
|||||||
return "Gráficos Gerais";
|
return "Gráficos Gerais";
|
||||||
|
|
||||||
case "meteo":
|
case "meteo":
|
||||||
return "Meteorologia";
|
return "Previsões";
|
||||||
|
case "meteoCharts":
|
||||||
|
return "Gráficos";
|
||||||
|
|
||||||
case "synoptic":
|
case "synoptic":
|
||||||
return "Sinótico";
|
return "Sinótico";
|
||||||
|
|||||||
@@ -30,31 +30,36 @@ type SidebarProps = {
|
|||||||
|
|
||||||
const RADIUS = "rounded-[6px]";
|
const RADIUS = "rounded-[6px]";
|
||||||
|
|
||||||
const navigationItems: {
|
const meteoItems: {
|
||||||
label: string;
|
label: string;
|
||||||
page: AppPage;
|
page: AppPage;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ label: "Painel Principal", page: "dashboard", icon: Home },
|
{ label: "Previsões", page: "meteo", icon: CloudSun },
|
||||||
{ label: "Meteorologia", page: "meteo", icon: CloudSun },
|
{ label: "Gráficos", page: "meteoCharts", icon: BarChart3 },
|
||||||
{ label: "Gráficos Gerais", page: "maincharts", icon: BarChart3 },
|
|
||||||
{ label: "Sinótico", page: "synoptic" , icon: MonitorDot }
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const climateItems = [
|
const climateItems: {
|
||||||
|
label: string;
|
||||||
|
page?: AppPage;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}[] = [
|
||||||
{ label: "Iluminação", icon: Lightbulb },
|
{ label: "Iluminação", icon: Lightbulb },
|
||||||
{ label: "Ventilação", icon: Wind },
|
{ 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: "Regas", icon: Droplet },
|
||||||
{ label: "Filtros de Rega", icon: Filter },
|
{ label: "Filtros de Rega", icon: Filter },
|
||||||
{ label: "Consumos", icon: Gauge },
|
{ label: "Consumos", icon: Gauge },
|
||||||
{ label: "Drenagem", icon: Waves },
|
{ label: "Drenagem", icon: Waves },
|
||||||
{ label: "Gráficos", icon: BarChart3 },
|
{ label: "Gráficos", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const utilityItems: {
|
const utilityItems: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -74,30 +79,40 @@ export function Sidebar({
|
|||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
const [meteoOpen, setMeteoOpen] = useState(true);
|
||||||
const [climateOpen, setClimateOpen] = useState(false);
|
const [climateOpen, setClimateOpen] = useState(false);
|
||||||
const [irrigationOpen, setIrrigationOpen] = useState(false);
|
const [irrigationOpen, setIrrigationOpen] = useState(false);
|
||||||
const [activeTreeItem, setActiveTreeItem] = useState<string | null>(null);
|
const [activeTreeItem, setActiveTreeItem] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleTreeClick = (key: string) => {
|
const handleTreeClick = (key: string, page?: AppPage) => {
|
||||||
setActiveTreeItem(key);
|
setActiveTreeItem(key);
|
||||||
|
|
||||||
if (key === "climate:Gráficos") {
|
if (page) {
|
||||||
onNavigate("climateCharts");
|
onNavigate(page);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTreeToggle = (section: "climate" | "irrigation") => {
|
const handleTreeToggle = (section: "meteo" | "climate" | "irrigation") => {
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
onToggleCollapsed();
|
onToggleCollapsed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (section === "meteo") {
|
||||||
|
setMeteoOpen((current) => !current);
|
||||||
|
setClimateOpen(false);
|
||||||
|
setIrrigationOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (section === "climate") {
|
if (section === "climate") {
|
||||||
setClimateOpen((current) => !current);
|
setClimateOpen((current) => !current);
|
||||||
|
setMeteoOpen(false);
|
||||||
setIrrigationOpen(false);
|
setIrrigationOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIrrigationOpen((current) => !current);
|
setIrrigationOpen((current) => !current);
|
||||||
|
setMeteoOpen(false);
|
||||||
setClimateOpen(false);
|
setClimateOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,10 +120,8 @@ export function Sidebar({
|
|||||||
<aside
|
<aside
|
||||||
className={
|
className={
|
||||||
isDark
|
isDark
|
||||||
? `${collapsed ? "w-20" : "w-[290px]"
|
? `${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`
|
||||||
} 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-[#D7DEE8] bg-[#F3F6FA] px-4 py-5 text-[#0F172A] transition-all duration-200`
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -152,27 +165,61 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-1.5">
|
<nav className="space-y-1.5">
|
||||||
{navigationItems.map((item) => {
|
<NavItem
|
||||||
const Icon = item.icon;
|
theme={theme}
|
||||||
const active = activePage === item.page && activeTreeItem === null;
|
collapsed={collapsed}
|
||||||
|
label="Painel Principal"
|
||||||
return (
|
page="dashboard"
|
||||||
<button
|
icon={Home}
|
||||||
key={item.label}
|
activePage={activePage}
|
||||||
type="button"
|
activeTreeItem={activeTreeItem}
|
||||||
onClick={() => {
|
onNavigate={(page) => {
|
||||||
setActiveTreeItem(null);
|
setActiveTreeItem(null);
|
||||||
onNavigate(item.page);
|
onNavigate(page);
|
||||||
}}
|
}}
|
||||||
title={collapsed ? item.label : undefined}
|
/>
|
||||||
className={navButtonClass(isDark, active, collapsed)}
|
|
||||||
>
|
<TreeSection
|
||||||
{active && <ActiveIndicator isDark={isDark} />}
|
theme={theme}
|
||||||
<Icon className={navIconClass(isDark, active)} />
|
collapsed={collapsed}
|
||||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
label="Meteorologia"
|
||||||
</button>
|
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" />
|
<SectionLabel collapsed={collapsed} label="Operação" />
|
||||||
|
|
||||||
@@ -186,6 +233,7 @@ export function Sidebar({
|
|||||||
items={climateItems}
|
items={climateItems}
|
||||||
sectionKey="climate"
|
sectionKey="climate"
|
||||||
activeTreeItem={activeTreeItem}
|
activeTreeItem={activeTreeItem}
|
||||||
|
activePage={activePage}
|
||||||
onItemClick={handleTreeClick}
|
onItemClick={handleTreeClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -199,6 +247,7 @@ export function Sidebar({
|
|||||||
items={irrigationItems}
|
items={irrigationItems}
|
||||||
sectionKey="irrigation"
|
sectionKey="irrigation"
|
||||||
activeTreeItem={activeTreeItem}
|
activeTreeItem={activeTreeItem}
|
||||||
|
activePage={activePage}
|
||||||
onItemClick={handleTreeClick}
|
onItemClick={handleTreeClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -259,6 +308,7 @@ function TreeSection({
|
|||||||
items,
|
items,
|
||||||
sectionKey,
|
sectionKey,
|
||||||
activeTreeItem,
|
activeTreeItem,
|
||||||
|
activePage,
|
||||||
onItemClick,
|
onItemClick,
|
||||||
}: {
|
}: {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
@@ -267,15 +317,18 @@ function TreeSection({
|
|||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
items: { label: string; icon: React.ElementType }[];
|
items: { label: string; page?: AppPage; icon: React.ElementType }[];
|
||||||
sectionKey: string;
|
sectionKey: string;
|
||||||
activeTreeItem: string | null;
|
activeTreeItem: string | null;
|
||||||
onItemClick: (key: string) => void;
|
activePage: AppPage;
|
||||||
|
onItemClick: (key: string, page?: AppPage) => void;
|
||||||
}) {
|
}) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const hasActiveChild = items.some(
|
const hasActiveChild = items.some((item) => {
|
||||||
(item) => activeTreeItem === `${sectionKey}:${item.label}`,
|
const key = `${sectionKey}:${item.label}`;
|
||||||
);
|
|
||||||
|
return activeTreeItem === key || Boolean(item.page && activePage === item.page);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -311,13 +364,14 @@ function TreeSection({
|
|||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const SubIcon = item.icon;
|
const SubIcon = item.icon;
|
||||||
const key = `${sectionKey}:${item.label}`;
|
const key = `${sectionKey}:${item.label}`;
|
||||||
const active = activeTreeItem === key;
|
const active =
|
||||||
|
activeTreeItem === key || Boolean(item.page && activePage === item.page);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onItemClick(key)}
|
onClick={() => onItemClick(key, item.page)}
|
||||||
className={
|
className={
|
||||||
active
|
active
|
||||||
? isDark
|
? 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-[#6F819B]"
|
||||||
: "h-5 w-5 shrink-0 text-slate-500";
|
: "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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{forecast?.current.condition?.icon && (
|
{forecast?.daily?.[0]?.condition?.icon && (
|
||||||
<img
|
<img
|
||||||
src={forecast.current.condition.icon}
|
src={forecast.daily[0].condition.icon}
|
||||||
alt={forecast.current.condition.text}
|
alt={forecast.daily[0].condition.text}
|
||||||
className="h-12 w-12 opacity-90"
|
className="h-12 w-12 opacity-90"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -200,14 +200,14 @@ function TodayForecastHero({
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
icon={Wind}
|
icon={Wind}
|
||||||
label="Vento"
|
label="Vento"
|
||||||
value={`${forecast.current.windKph.toFixed(1)} km/h`}
|
value={`${forecast.daily[0].maxWindKph.toFixed(1)} km/h`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WeatherMiniStat
|
<WeatherMiniStat
|
||||||
theme={theme}
|
theme={theme}
|
||||||
icon={Droplets}
|
icon={Droplets}
|
||||||
label="Humidade"
|
label="Humidade"
|
||||||
value={`${forecast.current.humidity}%`}
|
value={`${forecast.daily[0].averageHumidity}%`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type MeteoSensorSet = {
|
|||||||
windSpeed?: ModuleSensorResponse;
|
windSpeed?: ModuleSensorResponse;
|
||||||
radiation?: ModuleSensorResponse;
|
radiation?: ModuleSensorResponse;
|
||||||
rain?: ModuleSensorResponse;
|
rain?: ModuleSensorResponse;
|
||||||
|
co2?: ModuleSensorResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function selectMeteoSensors(
|
export function selectMeteoSensors(
|
||||||
@@ -46,6 +47,11 @@ export function selectMeteoSensors(
|
|||||||
"chuva",
|
"chuva",
|
||||||
"rain",
|
"rain",
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
co2: maxNumericMatch(sensors, [
|
||||||
|
"co",
|
||||||
|
"co2",
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export function useAccumulatedHistory(
|
|||||||
key: sensorKey,
|
key: sensorKey,
|
||||||
range,
|
range,
|
||||||
});
|
});
|
||||||
|
const url = `${BACKEND_URL}/api/historian/accumulated?${params.toString()}`;
|
||||||
|
console.log("I AM HEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEERE");
|
||||||
|
console.log("[AccumulatedHistory URL]", url);
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/historian/accumulated?${params.toString()}`,
|
`${BACKEND_URL}/api/historian/accumulated?${params.toString()}`,
|
||||||
@@ -47,7 +50,19 @@ export function useAccumulatedHistory(
|
|||||||
throw new Error("Failed to load accumulated history");
|
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);
|
setBuckets(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import {
|
|||||||
Cloud,
|
Cloud,
|
||||||
CloudRain,
|
CloudRain,
|
||||||
Droplets,
|
Droplets,
|
||||||
|
Gauge,
|
||||||
|
Compass,
|
||||||
Eye,
|
Eye,
|
||||||
Sun,
|
Sun,
|
||||||
Zap,
|
Zap,
|
||||||
Thermometer,
|
Thermometer,
|
||||||
Wind,
|
Wind,
|
||||||
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import weatherBoardBackground from "../../../assets/meteo-pane.png";
|
||||||
import sunnyBackground from "../../../assets/weather/Sunny.png";
|
import sunnyBackground from "../../../assets/weather/Sunny.png";
|
||||||
import partlyCloudyBackground from "../../../assets/weather/Partly Cloudy.png";
|
import partlyCloudyBackground from "../../../assets/weather/Partly Cloudy.png";
|
||||||
import cloudyBackground from "../../../assets/weather/Cloudy.png";
|
import cloudyBackground from "../../../assets/weather/Cloudy.png";
|
||||||
@@ -38,7 +43,8 @@ import WorkspaceChart, {
|
|||||||
} from "../../../components/charts/WorkspaceChart";
|
} from "../../../components/charts/WorkspaceChart";
|
||||||
|
|
||||||
import type { HistorianPoint } from "../components/MeteoHistoryModal";
|
import type { HistorianPoint } from "../components/MeteoHistoryModal";
|
||||||
|
import { useAccumulatedHistory } from "../hooks/useAccumulatedHistory";
|
||||||
|
import type { AccumulatedBucket } from "../hooks/useAccumulatedHistory";
|
||||||
type MeteoPageProps = {
|
type MeteoPageProps = {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
};
|
};
|
||||||
@@ -75,16 +81,33 @@ type HistoryKey = (typeof HISTORY_KEYS)[keyof typeof HISTORY_KEYS];
|
|||||||
export function MeteoPage({ theme }: MeteoPageProps) {
|
export function MeteoPage({ theme }: MeteoPageProps) {
|
||||||
const { sensors } = useMeteoModuleStream();
|
const { sensors } = useMeteoModuleStream();
|
||||||
const weatherForecast = useWeatherForecast();
|
const weatherForecast = useWeatherForecast();
|
||||||
|
const [weatherBoardOpen, setWeatherBoardOpen] = useState(false);
|
||||||
const selected = selectMeteoSensors(sensors);
|
const selected = selectMeteoSensors(sensors);
|
||||||
|
|
||||||
const temperature = selected.temperature;
|
const temperature = selected.temperature;
|
||||||
const humidity = selected.humidity;
|
const humidity = selected.humidity;
|
||||||
const windDirection = selected.windDirection;
|
const windDirection = selected.windDirection;
|
||||||
const windSpeed = selected.windSpeed;
|
const windSpeed = selected.windSpeed;
|
||||||
|
const co2 = selected.co2;
|
||||||
const radiation = selected.radiation;
|
const radiation = selected.radiation;
|
||||||
const forecast = weatherForecast.forecast;
|
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(
|
const { pointsByKey, loading: historyLoading } = useMeteoMultiHistory(
|
||||||
[
|
[
|
||||||
temperature ?? null,
|
temperature ?? null,
|
||||||
@@ -118,6 +141,7 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
const currentWindSpeed = numericSensorValue(windSpeed);
|
const currentWindSpeed = numericSensorValue(windSpeed);
|
||||||
const currentRadiation = numericSensorValue(radiation);
|
const currentRadiation = numericSensorValue(radiation);
|
||||||
const currentWindDirection = numericSensorValue(windDirection);
|
const currentWindDirection = numericSensorValue(windDirection);
|
||||||
|
const currentCo2 = numericSensorValue(co2)
|
||||||
const currentDewPoint = calculateDewPoint(
|
const currentDewPoint = calculateDewPoint(
|
||||||
currentTemperature,
|
currentTemperature,
|
||||||
currentHumidity,
|
currentHumidity,
|
||||||
@@ -185,6 +209,7 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
selectedDayIndex={selectedForecastDayIndex}
|
selectedDayIndex={selectedForecastDayIndex}
|
||||||
loading={weatherForecast.loading}
|
loading={weatherForecast.loading}
|
||||||
error={weatherForecast.error}
|
error={weatherForecast.error}
|
||||||
|
onOpenWeatherBoard={() => setWeatherBoardOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ForecastPanel
|
<ForecastPanel
|
||||||
@@ -204,10 +229,10 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
windSpeed={currentWindSpeed}
|
windSpeed={currentWindSpeed}
|
||||||
dewPoint={currentDewPoint}
|
dewPoint={currentDewPoint}
|
||||||
radiation={currentRadiation}
|
radiation={currentRadiation}
|
||||||
temperatureUnit={temperature?.unit ?? "°C"}
|
temperatureUnit={displayUnit(temperature?.unit ?? "°C")}
|
||||||
humidityUnit={humidity?.unit ?? "%"}
|
humidityUnit={displayUnit(humidity?.unit ?? "%")}
|
||||||
windUnit={windSpeed?.unit ?? "km/h"}
|
windUnit={displayUnit(windSpeed?.unit ?? "km/h")}
|
||||||
radiationUnit={radiation?.unit ?? "W/m²"}
|
radiationUnit={displayUnit(radiation?.unit ?? "W/m²")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WindDirectionPanel
|
<WindDirectionPanel
|
||||||
@@ -226,6 +251,310 @@ export function MeteoPage({ theme }: MeteoPageProps) {
|
|||||||
hours={HISTORY_HOURS}
|
hours={HISTORY_HOURS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,6 +566,7 @@ function WeatherHeroPanel({
|
|||||||
selectedDayIndex,
|
selectedDayIndex,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
onOpenWeatherBoard,
|
||||||
}: {
|
}: {
|
||||||
theme: "dark" | "light";
|
theme: "dark" | "light";
|
||||||
forecast: WeatherForecastResponse | null;
|
forecast: WeatherForecastResponse | null;
|
||||||
@@ -244,6 +574,7 @@ function WeatherHeroPanel({
|
|||||||
selectedDayIndex: number;
|
selectedDayIndex: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
onOpenWeatherBoard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const day = selectedDay ?? forecast?.daily?.[0];
|
const day = selectedDay ?? forecast?.daily?.[0];
|
||||||
@@ -298,10 +629,11 @@ function WeatherHeroPanel({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<Activity className="h-4 w-4" />
|
||||||
Ver quadro legado
|
Abrir quadro meteorológico
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -733,18 +1065,34 @@ function WindDirectionPanel({
|
|||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="relative h-24 w-24">
|
<div className="relative h-24 w-24">
|
||||||
<div
|
<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={{
|
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)`,
|
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">
|
<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}
|
{cardinal}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-black text-slate-500">
|
|
||||||
|
<span className="text-[11px] font-black text-slate-500">
|
||||||
{degrees ?? "--"}°
|
{degrees ?? "--"}°
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1031,7 +1379,6 @@ function SmallStat({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function CompassLabel({
|
function CompassLabel({
|
||||||
label,
|
label,
|
||||||
className,
|
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