diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ddf0074..6fc9493 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,8 +1,13 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", - "windows": ["main", "chart-*"], + "description": "Capability for the main and chart windows", + "windows": [ + "main", + "chart-*", + "maincharts-*", + "climatecharts-*" + ], "permissions": [ "core:default", "opener:default", @@ -19,6 +24,9 @@ "core:window:allow-maximize", "core:window:allow-toggle-maximize", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + + "core:event:allow-listen", + "core:event:allow-emit" ] } \ No newline at end of file diff --git a/src/app/App.tsx b/src/app/App.tsx index 3869d76..2a41d19 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -9,24 +9,24 @@ import { ConsolePage } from "../features/console/pages/ConsolePage"; import { MainChartsPage } from "../features/maincharts/pages/MainChartsPage"; import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPage"; import { SettingsPage } from "../features/settings/pages/SettingsPage"; +import SynopticPage from "../features/synoptic/pages/SynopticPage"; export type AppPage = | "dashboard" | "meteo" | "console" | "maincharts" + | "synoptic" | "settings" | "climate" | "climateCharts" | "climateLighting" | "climateVentilation" - | "climateSynoptic" | "irrigation" | "irrigationCharts" | "irrigationFilters" | "irrigationConsumption" | "irrigationDrainage" - | "irrigationSynoptic"; function App() { const [activePage, setActivePage] = useState("dashboard"); @@ -59,6 +59,10 @@ function App() { return ; } + if (activePage === "synoptic") { + return ; + } + return (
{ + void currentWindow.startDragging(); + }} > {chart.title}
diff --git a/src/features/climate/pages/ClimateChartsPage.tsx b/src/features/climate/pages/ClimateChartsPage.tsx index 732586e..c17c02f 100644 --- a/src/features/climate/pages/ClimateChartsPage.tsx +++ b/src/features/climate/pages/ClimateChartsPage.tsx @@ -366,7 +366,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) { }; const closeDetachedChartWindow = async (chartId: string) => { - const label = `chart-${chartId}`; + const label = `climatecharts-${chartId}`; const existing = await WebviewWindow.getByLabel(label); if (!existing) return; diff --git a/src/features/maincharts/pages/MainChartsPage.tsx b/src/features/maincharts/pages/MainChartsPage.tsx index 39c8e73..6dc99bf 100644 --- a/src/features/maincharts/pages/MainChartsPage.tsx +++ b/src/features/maincharts/pages/MainChartsPage.tsx @@ -365,7 +365,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { }; const closeDetachedChartWindow = async (chartId: string) => { - const label = `chart-${chartId}`; + const label = `maincharts-${chartId}`; const existing = await WebviewWindow.getByLabel(label); if (!existing) return; diff --git a/src/features/meteo/hooks/useMeteoMultiHistory.ts b/src/features/meteo/hooks/useMeteoMultiHistory.ts new file mode 100644 index 0000000..1c8429e --- /dev/null +++ b/src/features/meteo/hooks/useMeteoMultiHistory.ts @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import type { ModuleSensorResponse } from "../../../types/meteo"; +import type { HistorianPoint } from "../components/MeteoHistoryModal"; + +const BACKEND_URL = "http://localhost:18450"; + +type SensorHistoryMap = Record; + +export function useMeteoMultiHistory( + sensors: Array, + hours = 6, +) { + const [pointsByKey, setPointsByKey] = useState({}); + const [loading, setLoading] = useState(false); + + const sensorKeys = sensors + .filter((sensor): sensor is ModuleSensorResponse => Boolean(sensor)) + .map((sensor) => sensor.key); + + useEffect(() => { + if (!sensorKeys.length) { + setPointsByKey({}); + return; + } + + const controller = new AbortController(); + + async function loadHistory() { + try { + setLoading(true); + + const to = new Date(); + const from = new Date(to.getTime() - hours * 60 * 60 * 1000); + + const entries = await Promise.all( + sensorKeys.map(async (key) => { + const params = new URLSearchParams({ + key, + from: from.toISOString(), + to: to.toISOString(), + }); + + const response = await fetch( + `${BACKEND_URL}/api/historian/series?${params.toString()}`, + { signal: controller.signal }, + ); + + if (!response.ok) { + throw new Error(`Failed to load history for ${key}`); + } + + const payload = (await response.json()) as HistorianPoint[]; + + return [key, payload] as const; + }), + ); + + setPointsByKey(Object.fromEntries(entries)); + } catch (error) { + if (controller.signal.aborted) return; + + console.error("Failed to load meteo histories", error); + setPointsByKey({}); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + loadHistory(); + + return () => controller.abort(); + }, [sensorKeys.join("|"), hours]); + + return { + pointsByKey, + loading, + }; +} \ No newline at end of file diff --git a/src/features/meteo/pages/MeteoPage.tsx b/src/features/meteo/pages/MeteoPage.tsx index 3b623d8..1e27a74 100644 --- a/src/features/meteo/pages/MeteoPage.tsx +++ b/src/features/meteo/pages/MeteoPage.tsx @@ -1,52 +1,80 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Activity, - ChartNoAxesColumnIncreasing, + Cloud, CloudRain, Droplets, - MoreHorizontal, + Eye, Sun, - Table2, + Zap, Thermometer, - TrendingUp, Wind, + type LucideIcon, } from "lucide-react"; +import sunnyBackground from "../../../assets/weather/Sunny.png"; +import partlyCloudyBackground from "../../../assets/weather/Partly Cloudy.png"; +import cloudyBackground from "../../../assets/weather/Cloudy.png"; +import rainyBackground from "../../../assets/weather/Rainy.png"; +import overcastBackground from "../../../assets/weather/Overcast.png"; +import foggyBackground from "../../../assets/weather/Foggy.png"; +import patchyRainBackground from "../../../assets/weather/Patchy Rain.png"; +import thunderstormBackground from "../../../assets/weather/Thunderstorm.png"; +import snowBackground from "../../../assets/weather/Snow.png"; +import snowstormBackground from "../../../assets/weather/Snowstorm.png"; import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream"; -import { MeteoHistoryModal } from "../components/MeteoHistoryModal"; -import type { ModuleSensorResponse } from "../../../types/meteo"; -import { useMeteoHistory } from "../hooks/useMeteoHistory"; -import { AccumulatedHistoryModal } from "../components/AccumulatedHistoryModal"; -import { - useAccumulatedHistory, - type AccumulatedRange, -} from "../hooks/useAccumulatedHistory"; +import { useMeteoMultiHistory } from "../hooks/useMeteoMultiHistory"; import { useWeatherForecast } from "../hooks/useWeatherForecast"; -import { WeatherForecastCard } from "../components/WeatherForecastCard"; +import type { WeatherForecastResponse } from "../../../types/weather"; import { numericSensorValue, selectMeteoSensors, } from "../domain/meteoSensorSelectors"; +import WorkspaceChart, { + type WorkspaceChartConfig, + type WorkspaceChartInterval, + type WorkspaceChartMode, + type WorkspaceChartTimeRange, +} from "../../../components/charts/WorkspaceChart"; + +import type { HistorianPoint } from "../components/MeteoHistoryModal"; + type MeteoPageProps = { theme: "dark" | "light"; }; -type HistoryMap = Record; -type Accent = "amber" | "blue" | "cyan" | "emerald"; +type ChartPoint = { + timestamp: string; + value: number; +}; -const MAX_HISTORY_POINTS = 34; +type ChartSeries = { + key: HistoryKey; + title: string; + shortTitle: string; + unit: string; + values: ChartPoint[]; + accent: Accent; +}; + +type Accent = "amber" | "blue" | "cyan" | "emerald" | "violet"; + +const HISTORY_HOURS = 6; const RADIUS = "rounded-[5px]"; const HISTORY_KEYS = { temperature: "temperature", humidity: "humidity", windSpeed: "windSpeed", + windDirection: "windDirection", radiation: "radiation", - rain: "rain", } as const; +type HistoryKey = (typeof HISTORY_KEYS)[keyof typeof HISTORY_KEYS]; + export function MeteoPage({ theme }: MeteoPageProps) { const { sensors } = useMeteoModuleStream(); + const weatherForecast = useWeatherForecast(); const selected = selectMeteoSensors(sensors); @@ -55,608 +83,929 @@ export function MeteoPage({ theme }: MeteoPageProps) { const windDirection = selected.windDirection; const windSpeed = selected.windSpeed; const radiation = selected.radiation; - const rainSensor = selected.rain; + const forecast = weatherForecast.forecast; - const [selectedSensor, setSelectedSensor] = - useState(null); - - const [history, setHistory] = useState({}); - const [openMenu, setOpenMenu] = useState(null); - - const [selectedAccumulated, setSelectedAccumulated] = useState<{ - title: string; - sensor: ModuleSensorResponse | null; - } | null>(null); - - const [accumulatedRange, setAccumulatedRange] = - useState("7d"); - - const rainValue = numericSensorValue(rainSensor); - const isRaining = rainValue !== null && rainValue > 0; - - const meteoHistory = useMeteoHistory(selectedSensor); - - const accumulatedHistory = useAccumulatedHistory( - selectedAccumulated?.sensor ?? null, - accumulatedRange, + const { pointsByKey, loading: historyLoading } = useMeteoMultiHistory( + [ + temperature ?? null, + humidity ?? null, + windSpeed ?? null, + windDirection ?? null, + radiation ?? null, + ], + HISTORY_HOURS, ); + const [selectedForecastDayIndex, setSelectedForecastDayIndex] = useState(0); - const weatherForecast = useWeatherForecast(); + const forecastDays = forecast?.daily ?? []; + const selectedForecastDay = + forecastDays[selectedForecastDayIndex] ?? forecastDays[0] ?? null; useEffect(() => { - const samples: Array<[string, number | null]> = [ - [HISTORY_KEYS.temperature, numericSensorValue(temperature)], - [HISTORY_KEYS.humidity, numericSensorValue(humidity)], - [HISTORY_KEYS.windSpeed, numericSensorValue(windSpeed)], - [HISTORY_KEYS.radiation, numericSensorValue(radiation)], - [HISTORY_KEYS.rain, numericSensorValue(rainSensor)], - ]; + if (!forecastDays.length) { + setSelectedForecastDayIndex(0); + return; + } - setHistory((current) => { - const next = { ...current }; + if (selectedForecastDayIndex > forecastDays.length - 1) { + setSelectedForecastDayIndex(0); + } + }, [forecastDays.length, selectedForecastDayIndex]); - for (const [key, value] of samples) { - if (value === null || Number.isNaN(value)) continue; + // Live station readings only. Forecast values are handled by WeatherHeroPanel. + const currentTemperature = numericSensorValue(temperature); + const currentHumidity = numericSensorValue(humidity); + const currentWindSpeed = numericSensorValue(windSpeed); + const currentRadiation = numericSensorValue(radiation); + const currentWindDirection = numericSensorValue(windDirection); + const currentDewPoint = calculateDewPoint( + currentTemperature, + currentHumidity, + ); - const previous = next[key] ?? []; - const last = previous[previous.length - 1]; + const chartSeries = useMemo( + () => [ + { + key: HISTORY_KEYS.temperature, + title: "Temperatura (°C)", + shortTitle: "Temperatura", + unit: temperature?.unit ?? "°C", + values: historianSeries(pointsByKey[temperature?.key ?? ""]), + accent: "amber" as Accent, + }, + { + key: HISTORY_KEYS.humidity, + title: "Humidade (%)", + shortTitle: "Humidade", + unit: humidity?.unit ?? "%", + values: historianSeries(pointsByKey[humidity?.key ?? ""]), + accent: "blue" as Accent, + }, + { + key: HISTORY_KEYS.windSpeed, + title: "Vento (km/h)", + shortTitle: "Vento", + unit: windSpeed?.unit ?? "km/h", + values: historianSeries(pointsByKey[windSpeed?.key ?? ""]), + accent: "emerald" as Accent, + }, + { + key: HISTORY_KEYS.radiation, + title: "Radiação (W/m²)", + shortTitle: "Radiação", + unit: radiation?.unit ?? "W/m²", + values: historianSeries(pointsByKey[radiation?.key ?? ""]), + accent: "violet" as Accent, + }, + ], + [ + pointsByKey, + humidity?.key, + humidity?.unit, + radiation?.key, + radiation?.unit, + temperature?.key, + temperature?.unit, + windSpeed?.key, + windSpeed?.unit, + ], + ); - if (last === value && previous.length > 1) continue; - - next[key] = [...previous, value].slice(-MAX_HISTORY_POINTS); - } - - return next; - }); - }, [ - temperature?.value, - humidity?.value, - windSpeed?.value, - radiation?.value, - rainSensor?.value, - ]); + const windDirectionHistory = useMemo( + () => historianSeries(pointsByKey[windDirection?.key ?? ""]), + [pointsByKey, windDirection?.key], + ); return ( -
- + -
-
- } - accent="amber" - status={temperatureBadge(temperature)} - values={history[HISTORY_KEYS.temperature]} - menuOpen={openMenu === "temperature"} - onMenuToggle={() => - setOpenMenu(openMenu === "temperature" ? null : "temperature") - } - actions={[ - { - label: "Ver gráfico", - icon: , - onClick: () => { - setSelectedSensor(temperature ?? null); - setOpenMenu(null); - }, - }, - ]} - /> + - } - accent="blue" - status={humidityBadge(humidity)} - values={history[HISTORY_KEYS.humidity]} - menuOpen={openMenu === "humidity"} - onMenuToggle={() => - setOpenMenu(openMenu === "humidity" ? null : "humidity") - } - actions={[ - { - label: "Ver gráfico", - icon: , - onClick: () => { - setSelectedSensor(humidity ?? null); - setOpenMenu(null); - }, - }, - ]} - /> - - } - accent="emerald" - status={isRaining ? "A chover" : "Sem chuva"} - values={history[HISTORY_KEYS.rain]} - menuOpen={openMenu === "rain"} - onMenuToggle={() => - setOpenMenu(openMenu === "rain" ? null : "rain") - } - actions={[ - { - label: "Ver gráfico", - icon: , - onClick: () => { - setSelectedSensor(rainSensor ?? null); - setOpenMenu(null); - }, - }, - { - label: "Ver acumulado", - icon: , - onClick: () => { - setSelectedAccumulated({ - title: "Precipitação acumulada", - sensor: rainSensor ?? null, - }); - setOpenMenu(null); - }, - }, - ]} - /> -
- - + -
- } - accent="cyan" - status={windBadge(windSpeed)} - values={history[HISTORY_KEYS.windSpeed]} - menuOpen={openMenu === "wind"} - onMenuToggle={() => - setOpenMenu(openMenu === "wind" ? null : "wind") - } - actions={[ - { - label: "Ver gráfico", - icon: , - onClick: () => { - setSelectedSensor(windSpeed ?? null); - setOpenMenu(null); - }, - }, - ]} - /> + - } - accent="amber" - status={radiationBadge(radiation)} - values={history[HISTORY_KEYS.radiation]} - menuOpen={openMenu === "radiation"} - onMenuToggle={() => - setOpenMenu(openMenu === "radiation" ? null : "radiation") - } - actions={[ - { - label: "Ver gráfico", - icon: , - onClick: () => { - setSelectedSensor(radiation ?? null); - setOpenMenu(null); - }, - }, - { - label: "Ver acumulado", - icon: , - onClick: () => { - setSelectedAccumulated({ - title: "Radiação solar acumulada", - sensor: radiation ?? null, - }); - setOpenMenu(null); - }, - }, - ]} - /> -
+
- - setSelectedSensor(null)} - /> - - setSelectedAccumulated(null)} - />
); } -function MetricTile({ +function WeatherHeroPanel({ theme, - title, - subtitle, - sensor, - customValue, - customUnit, - icon, - accent, - status, - values, - menuOpen, - onMenuToggle, - actions, + forecast, + selectedDay, + selectedDayIndex, + loading, + error, }: { theme: "dark" | "light"; - title: string; - subtitle: string; - sensor?: ModuleSensorResponse; - customValue?: string; - customUnit?: string; - icon: ReactNode; - accent: Accent; - status: string; - values?: number[]; - menuOpen: boolean; - onMenuToggle: () => void; - actions: Array<{ - label: string; - icon: ReactNode; - onClick: () => void; - }>; + forecast: WeatherForecastResponse | null; + selectedDay: WeatherForecastResponse["daily"][number] | null; + selectedDayIndex: number; + loading: boolean; + error: string | null; }) { const isDark = theme === "dark"; - const colors = accentColors(accent, isDark); - const value = customValue ?? formatValue(sensor); - const unit = customUnit ?? sensor?.unit; - const trend = getTrend(values); + const day = selectedDay ?? forecast?.daily?.[0]; + + if (!day) { + return Sem previsão disponível.; + } + + const location = forecast + ? `${forecast.location.name}, ${forecast.location.country}` + : "Mira, Portugal"; + const conditionText = day.condition?.text ?? "--"; + const heroBackground = weatherBackgroundForCondition(conditionText); + const heroTemperature = day.maxTemperatureC ?? null; + const heroMinTemperature = day.minTemperatureC ?? null; + const heroDateLabel = + selectedDayIndex === 0 + ? "Hoje" + : selectedDayIndex === 1 + ? "Amanhã" + : weekday(day.date); return ( -
-
-
-
- {icon} -
+ +
+
+
+
-

- {title} +

+ {location}

-

- {subtitle} +

+ {selectedDayIndex === 0 + ? "Condições de hoje" + : `Previsão para ${heroDateLabel}`}

-
-
- - {menuOpen && ( -
- {actions.map((action) => ( - - ))} -
- )}
-
-
-
-
-
- {value} - {unit && ( - - {unit} - + {loading ? ( + A carregar meteorologia... + ) : error ? ( + {error} + ) : ( +
+
+ {day.condition?.icon ? ( + {day.condition.text} + ) : ( + )} + +
+
+ {formatNumber(heroTemperature, 1)}° +
+

+ {conditionText} +

+

+ {heroMinTemperature !== null + ? `Mínima ${formatNumber(heroMinTemperature, 0)}°` + : "Sem mínima disponível"} +

+
-
- - {status} - +
+ - + + - - {trend} - + icon={Wind} + /> + + + +
-
+ )}
- - -
+ ); } -function CompassPanel({ +function ForecastPanel({ + theme, + forecast, + loading, + error, + selectedIndex, + onSelectDay, +}: { + theme: "dark" | "light"; + forecast: WeatherForecastResponse | null; + loading: boolean; + error: string | null; + selectedIndex: number; + onSelectDay: (index: number) => void; +}) { + const isDark = theme === "dark"; + const days = forecast?.daily?.slice(0, 7) ?? []; + const selectedDay = days[selectedIndex] ?? days[0] ?? null; + + return ( +
+
+
+

Previsão diária

+

Próximos 7 dias

+
+ + {selectedDay && ( +
+

+ Dia selecionado +

+

+ {weekday(selectedDay.date)} · {shortDate(selectedDay.date)} +

+
+ )} +
+ + {loading ? ( + A carregar previsão... + ) : error ? ( + {error} + ) : !days.length ? ( + Sem previsão diária disponível. + ) : ( +
+ {days.map((day, index) => ( + onSelectDay(index)} + /> + ))} +
+ )} +
+ ); +} + +function ForecastDayCard({ + theme, + day, + index, + active, + onClick, +}: { + theme: "dark" | "light"; + day: WeatherForecastResponse["daily"][number]; + index: number; + active: boolean; + onClick: () => void; +}) { + const isDark = theme === "dark"; + const label = index === 0 ? "Hoje" : index === 1 ? "Amanhã" : weekday(day.date); + const conditionText = day.condition?.text ?? ""; + const background = weatherBackgroundForCondition(conditionText); + + return ( + + ); +} + +function WeatherSummaryPanel({ + theme, + temperature, + humidity, + windSpeed, + dewPoint, + temperatureUnit, + humidityUnit, + windUnit, + radiation, + radiationUnit +}: { + theme: "dark" | "light"; + temperature: number | null; + humidity: number | null; + windSpeed: number | null; + dewPoint: number | null; + temperatureUnit: string; + humidityUnit: string; + windUnit: string; + radiation: number | null; + radiationUnit: string; +}) { + const isDark = theme === "dark"; + + return ( +
+

Resumo meteorológico

+ +
+ + + + + +
+
+ ); +} + +function radiationBadge(value: number | null) { + if (value === null) return "Sem dados"; + if (value >= 800) return "Alta"; + if (value >= 400) return "Média"; + return "Baixa"; +} + +function WindDirectionPanel({ theme, direction, + history, + windHistory, }: { theme: "dark" | "light"; direction: number | null; + history: Array<{ + timestamp: string; + value: number; + }>; + windHistory: Array<{ + timestamp: string; + value: number; + }>; }) { const isDark = theme === "dark"; const angle = direction ?? 0; const cardinal = directionName(direction); const degrees = direction !== null ? Math.round(direction) : null; + const previousDirection = + history.length > 0 + ? directionName(history[history.length - 1].value) + : "--"; + + const currentDirection = directionName(direction); + + const directionTrend = + previousDirection === currentDirection + ? currentDirection + : `${previousDirection} → ${currentDirection}`; + const maxGust = + windHistory.length > 0 + ? Math.max(...windHistory.map((point) => point.value)) + : null; + + const consistency = windConsistency(history); return ( -
-
- - - Direção do vento - +
+

Direção do vento

-

- {cardinal} -

+
+
+
+
+
-
- - -
-
- -
-
-
- -
- -
- - {Array.from({ length: 72 }).map((_, index) => { + {Array.from({ length: 48 }).map((_, index) => { const major = index % 6 === 0; - return ( ); })} - - - - - -
+ + + + + + + +
-
+
- -
- -
- -
+
+
+ + {cardinal} + + + {degrees ?? "--"}° + +
-
+ +
+ + + +
+ ); } -function CompassStat({ +function windConsistency( + history: Array<{ timestamp: string; value: number }> +) { + if (history.length < 2) return "--"; + + let totalVariation = 0; + + for (let i = 1; i < history.length; i++) { + const previous = history[i - 1].value; + const current = history[i].value; + + let delta = Math.abs(current - previous); + + if (delta > 180) { + delta = 360 - delta; + } + + totalVariation += delta; + } + + const averageVariation = totalVariation / (history.length - 1); + + if (averageVariation <= 5) return "Muito estável"; + if (averageVariation <= 15) return "Estável"; + if (averageVariation <= 35) return "Variável"; + + return "Instável"; +} + +function RealtimeChartPanel({ + theme, + series, + historyLoading, +}: { + theme: "dark" | "light"; + series: ChartSeries[]; + historyLoading: boolean; + hours: number; +}) { + const [mode, setMode] = useState("line"); + const [timeRange, setTimeRange] = useState("6h"); + const [interval, setInterval] = useState("5m"); + const [visibleKeys, setVisibleKeys] = useState([ + HISTORY_KEYS.temperature, + HISTORY_KEYS.humidity, + ]); + + const chart: WorkspaceChartConfig = { + id: "meteo-realtime", + title: "Gráficos em tempo real", + subtitle: "Leituras históricas da estação meteorológica", + icon: Activity, + status: "online", + sourceLabel: "Estação", + mode, + timeRange, + interval, + variables: series.map((item) => ({ + key: item.key, + label: item.shortTitle, + unit: item.unit, + color: chartColor(item.accent), + visible: visibleKeys.includes(item.key), + data: item.values.map((point) => ({ + timestamp: point.timestamp, + value: point.value, + })), + })), + }; + + function handleVariableToggle(variableKey: string) { + const key = variableKey as HistoryKey; + + setVisibleKeys((current) => { + if (current.includes(key)) { + return current.length <= 1 + ? current + : current.filter((item) => item !== key); + } + + return [...current, key]; + }); + } + + return ( +
+
+ +
+ + + + +
+ ); +} + +function chartColor(accent: Accent) { + switch (accent) { + case "amber": + return "#facc15"; + case "blue": + return "#38bdf8"; + case "cyan": + return "#67e8f9"; + case "emerald": + return "#34d399"; + case "violet": + return "#a78bfa"; + } +} + +function CompactMeteoChart({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +function HeroMetric({ + title, + value, + helper, + icon: Icon, +}: { + title: string; + value: string; + helper: string; + icon: LucideIcon; +}) { + return ( +
+
+ + {title} +
+

{value}

+

{helper}

+
+ ); +} + +function SummaryTile({ + theme, + icon: Icon, + accent, + label, + value, + badge, +}: { + theme: "dark" | "light"; + icon: LucideIcon; + accent: Accent; + label: string; + value: string; + badge: string; +}) { + const isDark = theme === "dark"; + const colors = accentColors(accent); + + return ( +
+
+ +

+ {label} +

+
+ +

+ {value} +

+ +
+ + {badge} + +
+
+ ); +} +function SmallStat({ theme, label, value, - highlighted, }: { theme: "dark" | "light"; label: string; value: string; - highlighted?: boolean; }) { const isDark = theme === "dark"; @@ -664,29 +1013,16 @@ function CompassStat({
+

{label}

- {label} -

- -

{value} @@ -695,6 +1031,7 @@ function CompassStat({ ); } + function CompassLabel({ label, className, @@ -706,9 +1043,7 @@ function CompassLabel({ }) { return ( {label} @@ -716,72 +1051,155 @@ function CompassLabel({ ); } -function Sparkline({ - values, - className, - strokeClassName, - glowClassName, +function PanelState({ + theme, + children, }: { - values?: number[]; - className?: string; - strokeClassName: string; - glowClassName: string; + theme: "dark" | "light"; + children: string; }) { - if (!values || values.length < 2) return null; - - const width = 220; - const height = 62; - const padding = 8; - const min = Math.min(...values); - const max = Math.max(...values); - const range = max - min || 1; - - const points = values.map((value, index) => { - const x = padding + (index / (values.length - 1)) * (width - padding * 2); - const y = padding + (1 - (value - min) / range) * (height - padding * 2); - return `${x},${y}`; - }); - - const last = points[points.length - 1].split(",").map(Number); - + const isDark = theme === "dark"; return ( - + {children} +

); } -function formatValue(sensor?: ModuleSensorResponse) { - if (!sensor) return "--"; +function HeroState({ children }: { children: string }) { + return ( +
+ {children} +
+ ); +} - if (typeof sensor.value === "number") { - return Number.isInteger(sensor.value) - ? String(sensor.value) - : sensor.value.toFixed(1); +function panelClass(isDark: boolean) { + return isDark + ? `${RADIUS} flex h-full flex-col border border-white/10 bg-[#071421] p-3 shadow-[0_14px_34px_rgba(0,0,0,0.22)]` + : `${RADIUS} flex h-full flex-col border border-slate-200 bg-white p-3 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`; +} + +function panelTitleClass(isDark: boolean) { + return isDark + ? "text-base font-black text-slate-100" + : "text-base font-black text-slate-950"; +} + +function calculateDewPoint( + temperature: number | null, + humidity: number | null, +) { + if (temperature === null || humidity === null || humidity <= 0) return null; + return temperature - (100 - humidity) / 5; +} + +function historianSeries(points: HistorianPoint[] | undefined) { + return (points ?? []) + .filter( + (point): point is HistorianPoint & { numericValue: number } => + point.numericValue !== null, + ) + .map((point) => ({ + timestamp: point.timestamp, + value: point.numericValue, + })); +} + +function weatherBackgroundForCondition(condition: string | null | undefined) { + const normalized = normalizeWeatherCondition(condition); + + if (!normalized) return sunnyBackground; + + if ( + normalized.includes("thunder") || + normalized.includes("trovoada") || + normalized.includes("relampago") || + normalized.includes("storm") + ) { + return thunderstormBackground; } - if (typeof sensor.value === "boolean") { - return sensor.value ? "On" : "Off"; + if ( + normalized.includes("snowstorm") || + normalized.includes("blizzard") || + normalized.includes("heavy snow") || + normalized.includes("neve forte") || + normalized.includes("tempestade de neve") + ) { + return snowstormBackground; } - if (sensor.value === null || sensor.value === undefined) { - return "--"; + if (normalized.includes("snow") || normalized.includes("neve")) { + return snowBackground; } - return String(sensor.value); + if ( + normalized.includes("patchy rain") || + normalized.includes("rain nearby") || + normalized.includes("possibilidade de chuva") || + normalized.includes("aguaceiros fracos") + ) { + return patchyRainBackground; + } + + if ( + normalized.includes("rain") || + normalized.includes("chuva") || + normalized.includes("aguaceiro") || + normalized.includes("drizzle") || + normalized.includes("chuvisco") + ) { + return rainyBackground; + } + + if ( + normalized.includes("fog") || + normalized.includes("mist") || + normalized.includes("nevoeiro") || + normalized.includes("neblina") + ) { + return foggyBackground; + } + + if (normalized.includes("overcast") || normalized.includes("encoberto")) { + return overcastBackground; + } + + if ( + normalized.includes("partly cloudy") || + normalized.includes("parcialmente nublado") || + normalized.includes("partly") || + normalized.includes("parcialmente") + ) { + return partlyCloudyBackground; + } + + if (normalized.includes("cloudy") || normalized.includes("nublado")) { + return cloudyBackground; + } + + return sunnyBackground; +} + +function normalizeWeatherCondition(condition: string | null | undefined) { + return (condition ?? "") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); +} + +function formatNumber(value: number | null | undefined, decimals = 0) { + if (value === null || value === undefined || Number.isNaN(value)) return "--"; + return Number.isInteger(value) || decimals === 0 + ? String(Math.round(value)) + : value.toFixed(decimals); } function directionName(direction: number | null) { @@ -793,126 +1211,84 @@ function directionName(direction: number | null) { return labels[index]; } -function directionQuadrant(direction: number | null) { - if (direction === null) return "--"; - if (direction >= 315 || direction < 45) return "Norte"; - if (direction >= 45 && direction < 135) return "Este"; - if (direction >= 135 && direction < 225) return "Sul"; - return "Oeste"; +function weekday(date: string) { + return new Date(date) + .toLocaleDateString("pt-PT", { weekday: "long" }) + .replace(/^./, (letter) => letter.toUpperCase()); } -function getTrend(values?: number[]) { - if (!values || values.length < 2) return "Sem tendência"; - - const first = values[0]; - const last = values[values.length - 1]; - const delta = last - first; - - if (Math.abs(delta) < 0.1) return "Estável"; - if (delta > 0) return "A subir"; - return "A descer"; +function shortDate(date: string) { + return new Date(date) + .toLocaleDateString("pt-PT", { day: "2-digit", month: "short" }) + .replace(".", ""); } -function temperatureBadge(sensor?: ModuleSensorResponse) { - const value = numericSensorValue(sensor); +function temperatureBadge(value: number | null) { if (value === null) return "Sem dados"; if (value >= 30) return "Quente"; if (value <= 10) return "Frio"; return "Normal"; } -function humidityBadge(sensor?: ModuleSensorResponse) { - const value = numericSensorValue(sensor); +function humidityBadge(value: number | null) { if (value === null) return "Sem dados"; if (value >= 80) return "Alta"; if (value <= 35) return "Baixa"; return "Normal"; } -function windBadge(sensor?: ModuleSensorResponse) { - const value = numericSensorValue(sensor); +function windBadge(value: number | null) { if (value === null) return "Sem dados"; if (value >= 30) return "Forte"; if (value >= 10) return "Moderado"; return "Fraco"; } -function radiationBadge(sensor?: ModuleSensorResponse) { - const value = numericSensorValue(sensor); - if (value === null) return "Sem dados"; - if (value >= 800) return "Alta"; - if (value >= 400) return "Média"; - return "Baixa"; +function uvLabel(value: number | null | undefined) { + if (value === null || value === undefined) return "--"; + if (value >= 11) return "Extremo"; + if (value >= 8) return "Muito elevado"; + if (value >= 6) return "Elevado"; + if (value >= 3) return "Moderado"; + return "Baixo"; } -function accentColors(accent: Accent, isDark: boolean) { +function accentColors(accent: Accent) { switch (accent) { case "amber": - return isDark - ? { - icon: "text-amber-200", - stroke: "stroke-amber-200", - dot: "fill-amber-200", - iconBox: "border-white/10 bg-white/[0.03]", - badge: "border-white/10 bg-white/[0.03] text-slate-300", - } - : { - icon: "text-amber-700", - stroke: "stroke-amber-500", - dot: "fill-amber-500", - iconBox: "border-slate-200 bg-slate-50", - badge: "border-slate-200 bg-slate-50 text-slate-600", - }; - + return { + text: "text-amber-300", + stroke: "stroke-amber-300", + fill: "fill-amber-300", + bg: "bg-amber-300", + }; case "blue": - return isDark - ? { - icon: "text-sky-200", - stroke: "stroke-sky-200", - dot: "fill-sky-200", - iconBox: "border-white/10 bg-white/[0.03]", - badge: "border-white/10 bg-white/[0.03] text-slate-300", - } - : { - icon: "text-sky-700", - stroke: "stroke-sky-500", - dot: "fill-sky-500", - iconBox: "border-slate-200 bg-slate-50", - badge: "border-slate-200 bg-slate-50 text-slate-600", - }; - + return { + text: "text-sky-400", + stroke: "stroke-sky-400", + fill: "fill-sky-400", + bg: "bg-sky-400", + }; case "cyan": - return isDark - ? { - icon: "text-cyan-200", - stroke: "stroke-cyan-200", - dot: "fill-cyan-200", - iconBox: "border-white/10 bg-white/[0.03]", - badge: "border-white/10 bg-white/[0.03] text-slate-300", - } - : { - icon: "text-cyan-700", - stroke: "stroke-cyan-500", - dot: "fill-cyan-500", - iconBox: "border-slate-200 bg-slate-50", - badge: "border-slate-200 bg-slate-50 text-slate-600", - }; - + return { + text: "text-cyan-300", + stroke: "stroke-cyan-300", + fill: "fill-cyan-300", + bg: "bg-cyan-300", + }; case "emerald": - return isDark - ? { - icon: "text-emerald-200", - stroke: "stroke-emerald-200", - dot: "fill-emerald-200", - iconBox: "border-white/10 bg-white/[0.03]", - badge: "border-white/10 bg-white/[0.03] text-slate-300", - } - : { - icon: "text-emerald-700", - stroke: "stroke-emerald-500", - dot: "fill-emerald-500", - iconBox: "border-slate-200 bg-slate-50", - badge: "border-slate-200 bg-slate-50 text-slate-600", - }; + return { + text: "text-emerald-400", + stroke: "stroke-emerald-400", + fill: "fill-emerald-400", + bg: "bg-emerald-400", + }; + case "violet": + return { + text: "text-violet-400", + stroke: "stroke-violet-400", + fill: "fill-violet-400", + bg: "bg-violet-400", + }; } } \ No newline at end of file diff --git a/src/features/synoptic/hooks/useSynopticVariables.ts b/src/features/synoptic/hooks/useSynopticVariables.ts new file mode 100644 index 0000000..bdc814f --- /dev/null +++ b/src/features/synoptic/hooks/useSynopticVariables.ts @@ -0,0 +1,206 @@ +import { useMemo } from "react"; +import type { LucideIcon } from "lucide-react"; +import { + Droplet, + Gauge, + Home, + Layers3, + Lightbulb, + Thermometer, + Wind, +} from "lucide-react"; + +import type { ModuleSensorResponse } from "../../../types/meteo"; +import { useClimateModuleStream } from "../../climate/hooks/useClimateModuleStream"; + +export type SynopticVariableGroup = "Clima" | "Rega"; + +export type SynopticVariable = { + id: string; + sensorId: string; + key: string; + group: SynopticVariableGroup; + label: string; + value: string | number | boolean | null; + unit: string; + timestamp?: string; + connected: boolean; + icon: LucideIcon; + color: string; +}; + +export function useSynopticVariables() { + const climate = useClimateModuleStream(); + + const variables = useMemo( + () => + toSynopticVariables({ + sensors: climate.sensors, + group: "Clima", + connected: climate.connected, + fallbackTimestamp: climate.lastTimestamp, + }), + [ + climate.sensors, + climate.connected, + climate.lastTimestamp, + ], + ); + + return { + variables, + groups: groupVariables(variables), + connected: climate.connected, + climateConnected: climate.connected, + loading: !climate.module, + total: variables.length, + }; +} + +function toSynopticVariables({ + sensors, + group, + connected, + fallbackTimestamp, +}: { + sensors: ModuleSensorResponse[]; + group: SynopticVariableGroup; + connected: boolean; + fallbackTimestamp: string | null; +}): SynopticVariable[] { + return sensors.map((sensor) => { + const visual = getVariableVisual(sensor, group); + + return { + id: `${group}:${sensor.sensorId ?? sensor.key}`, + sensorId: String(sensor.sensorId ?? sensor.key), + key: sensor.key, + group, + label: sensor.name || sensor.key, + value: normalizeValue(sensor.value), + unit: sensor.unit ?? "", + timestamp: sensor.timestamp ?? fallbackTimestamp ?? undefined, + connected, + icon: visual.icon, + color: visual.color, + }; + }); +} + +function groupVariables(variables: SynopticVariable[]) { + return variables.reduce>( + (acc, variable) => { + acc[variable.group].push(variable); + return acc; + }, + { + Clima: [], + Rega: [], + }, + ); +} + +function normalizeValue(value: unknown): string | number | boolean | null { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null + ) { + return value; + } + + return null; +} + +function getVariableVisual( + sensor: ModuleSensorResponse, + group: SynopticVariableGroup, +): { + icon: LucideIcon; + color: string; +} { + const text = normalizeText(`${sensor.key} ${sensor.name} ${sensor.unit ?? ""}`); + + if ( + text.includes("temperatura") || + text.includes("temperature") + ) { + return { + icon: Thermometer, + color: "#4FD1C5", + }; + } + + if ( + text.includes("humidade") || + text.includes("humidity") + ) { + return { + icon: Droplet, + color: "#38BDF8", + }; + } + + if ( + text.includes("vento") || + text.includes("wind") + ) { + return { + icon: Wind, + color: "#22D3EE", + }; + } + + if ( + text.includes("radiacao") || + text.includes("radiação") || + text.includes("radiation") + ) { + return { + icon: Lightbulb, + color: "#FACC15", + }; + } + + if ( + text.includes("pressao") || + text.includes("pressão") || + text.includes("pressure") + ) { + return { + icon: Gauge, + color: "#38BDF8", + }; + } + + if ( + text.includes("nivel") || + text.includes("nível") || + text.includes("level") + ) { + return { + icon: Layers3, + color: "#0EA5E9", + }; + } + + if (group === "Clima") { + return { + icon: Home, + color: "#22C55E", + }; + } + + return { + icon: Gauge, + color: "#A3E635", + }; +} + +function normalizeText(value: string): string { + return value + .toLowerCase() + .normalize("NFD") + .replace(/\p{Diacritic}/gu, ""); +} \ No newline at end of file diff --git a/src/features/synoptic/pages/SynopticPage.tsx b/src/features/synoptic/pages/SynopticPage.tsx new file mode 100644 index 0000000..180f28a --- /dev/null +++ b/src/features/synoptic/pages/SynopticPage.tsx @@ -0,0 +1,1119 @@ +import { useEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode, type RefObject } from "react"; +import { + ChevronDown, + FolderPlus, + LocateFixed, + Move, + Plus, + RefreshCw, + Save, + Search, + Settings2, + X, + ZoomIn, + ZoomOut, +} from "lucide-react"; + +import satelliteImage from "../../../assets/sattelite169.png"; +import { useSynopticVariables } from "../hooks/useSynopticVariables"; +import type { SynopticVariable } from "../hooks/useSynopticVariables"; + +type SynopticPageProps = { + theme: "dark" | "light"; +}; + +type CardVariant = "compact" | "large"; + +type MapItem = { + id: string; + variableId: string; + x: number; + y: number; + variant: CardVariant; + showLabel: boolean; +}; + +const initialMapItems: MapItem[] = []; + +export function SynopticPage({ theme }: SynopticPageProps) { + const isDark = theme === "dark"; + const canvasRef = useRef(null); + const synoptic = useSynopticVariables(); + + const [items, setItems] = useState(initialMapItems); + const [selectedItemId, setSelectedItemId] = useState(null); + const [activeGroup, setActiveGroup] = useState("Todas"); + const [search, setSearch] = useState(""); + + const [pendingVariableDrag, setPendingVariableDrag] = useState<{ + id: string; + pointerId: number; + } | null>(null); + + const [dragState, setDragState] = useState<{ + id: string; + pointerId: number; + offsetX: number; + offsetY: number; + } | null>(null); + + const variablesById = useMemo( + () => + new Map( + synoptic.variables.map((variable) => [ + variable.id, + variable, + ]), + ), + [synoptic.variables], + ); + + const selectedItem = + items.find((item) => item.id === selectedItemId) ?? + null; + + const selectedVariable = + selectedItem ? variablesById.get(selectedItem.variableId) ?? null : null; + + + const placedVariableIds = useMemo( + () => new Set(items.map((item) => item.variableId)), + [items], + ); + + const visiblePlacedVariableIds = useMemo(() => { + const ids = new Set(placedVariableIds); + + if (pendingVariableDrag) { + const pendingItem = items.find((item) => item.id === pendingVariableDrag.id); + + if (pendingItem) { + ids.delete(pendingItem.variableId); + } + } + + return ids; + }, [items, placedVariableIds, pendingVariableDrag]); + + const availableGroups = useMemo( + () => + Array.from( + new Set( + synoptic.variables + .filter((variable) => !visiblePlacedVariableIds.has(variable.id)) + .map((variable) => variable.group), + ), + ), + [synoptic.variables, visiblePlacedVariableIds], + ); + + useEffect(() => { + if (activeGroup !== "Todas" && !availableGroups.includes(activeGroup)) { + setActiveGroup("Todas"); + } + }, [activeGroup, availableGroups]); + + function getPointInCanvas(event: PointerEvent) { + const rect = canvasRef.current?.getBoundingClientRect(); + + if (!rect) { + return null; + } + + const x = ((event.clientX - rect.left) / rect.width) * 100; + const y = ((event.clientY - rect.top) / rect.height) * 100; + + if (x < 0 || x > 100 || y < 0 || y > 100) { + return null; + } + + return { x, y }; + } + + function handleCardPointerDown(event: PointerEvent, item: MapItem) { + const point = getPointInCanvas(event); + + if (!point) { + return; + } + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + + setSelectedItemId(item.id); + + setDragState({ + id: item.id, + pointerId: event.pointerId, + offsetX: point.x - item.x, + offsetY: point.y - item.y, + }); + } + + function handleCardPointerMove(event: PointerEvent) { + if (!dragState || event.pointerId !== dragState.pointerId) { + return; + } + + const point = getPointInCanvas(event); + + if (!point) { + return; + } + + setItems((currentItems) => + currentItems.map((item) => + item.id === dragState.id + ? { + ...item, + x: clampPercent(point.x - dragState.offsetX), + y: clampPercent(point.y - dragState.offsetY), + } + : item, + ), + ); + } + + function handleCardPointerUp(event: PointerEvent) { + if (dragState && event.pointerId === dragState.pointerId) { + event.currentTarget.releasePointerCapture(event.pointerId); + setDragState(null); + } + } + + function createMapItemFromVariable(variable: SynopticVariable, point: { x: number; y: number }) { + const newItem: MapItem = { + id: `map-item-${crypto.randomUUID()}`, + variableId: variable.id, + x: clampPercent(point.x), + y: clampPercent(point.y), + variant: "compact", + showLabel: true, + }; + + setItems((currentItems) => [...currentItems, newItem]); + setSelectedItemId(newItem.id); + + return newItem; + } + + function handleVariablePointerDown( + event: PointerEvent, + variable: SynopticVariable, + ) { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + + const point = getPointInCanvas(event) ?? { + x: 50, + y: 50, + }; + + const newItem = createMapItemFromVariable(variable, point); + + setPendingVariableDrag({ + id: newItem.id, + pointerId: event.pointerId, + }); + + setDragState({ + id: newItem.id, + pointerId: event.pointerId, + offsetX: 0, + offsetY: 0, + }); + } + + function handleVariablePointerMove(event: PointerEvent) { + if (!pendingVariableDrag || event.pointerId !== pendingVariableDrag.pointerId) { + return; + } + + const point = getPointInCanvas(event); + + if (!point) { + return; + } + + setItems((currentItems) => + currentItems.map((item) => + item.id === pendingVariableDrag.id + ? { + ...item, + x: clampPercent(point.x), + y: clampPercent(point.y), + } + : item, + ), + ); + } + + function handleVariablePointerUp(event: PointerEvent) { + if (!pendingVariableDrag || event.pointerId !== pendingVariableDrag.pointerId) { + return; + } + + const point = getPointInCanvas(event); + + if (!point) { + setItems((currentItems) => + currentItems.filter((item) => item.id !== pendingVariableDrag.id), + ); + + if (selectedItemId === pendingVariableDrag.id) { + setSelectedItemId(null); + } + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + setPendingVariableDrag(null); + setDragState(null); + } + + function handleRemoveSelectedItem() { + if (!selectedItemId) { + return; + } + + setItems((currentItems) => + currentItems.filter((item) => item.id !== selectedItemId), + ); + + setSelectedItemId(null); + } + + function handleSelectedVariantChange(variant: CardVariant) { + if (!selectedItemId) { + return; + } + + setItems((currentItems) => + currentItems.map((item) => + item.id === selectedItemId + ? { + ...item, + variant, + } + : item, + ), + ); + } + + function handleSelectedShowLabelChange(showLabel: boolean) { + if (!selectedItemId) { + return; + } + + setItems((currentItems) => + currentItems.map((item) => + item.id === selectedItemId + ? { + ...item, + showLabel, + } + : item, + ), + ); + } + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if ( + event.key !== "Delete" && + event.key !== "Backspace" + ) { + return; + } + + if (!selectedItemId) { + return; + } + + const activeElement = document.activeElement; + + const typing = + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement instanceof HTMLSelectElement; + + if (typing) { + return; + } + + setItems((currentItems) => + currentItems.filter( + (item) => item.id !== selectedItemId, + ), + ); + + setSelectedItemId(null); + } + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [selectedItemId]); + + return ( +
+
+ + +
+ + +
+ + + +
+ + +
+
+
+ ); +} + +function WorkspaceBar() { + return ( +
+
+

+ Workspace sinótico +

+ +
+ +
+
+ +
+ } label="Novo" /> + } label="Guardar" primary /> + } label="Atualizar" /> + +
+ + Alterações guardadas +
+
+
+ ); +} + +function WorkspaceButton({ + icon, + label, + primary, +}: { + icon: ReactNode; + label: string; + primary?: boolean; +}) { + if (primary) { + return ( + + ); + } + + return ( + + ); +} + +function VariablesPanel({ + isDark, + variables, + placedVariableIds, + availableGroups, + activeGroup, + search, + onActiveGroupChange, + onSearchChange, + onVariablePointerDown, + onVariablePointerMove, + onVariablePointerUp, +}: { + isDark: boolean; + variables: SynopticVariable[]; + placedVariableIds: Set; + availableGroups: string[]; + activeGroup: string; + search: string; + onActiveGroupChange: (group: string) => void; + onSearchChange: (value: string) => void; + onVariablePointerDown: ( + event: PointerEvent, + variable: SynopticVariable, + ) => void; + onVariablePointerMove: (event: PointerEvent) => void; + onVariablePointerUp: (event: PointerEvent) => void; +}) { + const groups = availableGroups; + const tabs = ["Todas", ...availableGroups]; + + const filteredVariables = variables.filter((variable) => { + if (placedVariableIds.has(variable.id)) { + return false; + } + + const matchesGroup = + activeGroup === "Todas" || + variable.group === activeGroup; + + const query = search.trim().toLowerCase(); + + const matchesSearch = + query.length === 0 || + `${variable.label} ${variable.key} ${variable.unit}` + .toLowerCase() + .includes(query); + + return matchesGroup && matchesSearch; + }); + + return ( + + ); +} + +function Toolbar() { + const tools = [ + { label: "Selecionar", icon: , active: true }, + { label: "Mover", icon: }, + { label: "Texto", icon: T }, + ]; + + return ( +
+
+ {tools.map((tool) => ( + + ))} +
+ +
+ +
+
+ ); +} + +function CanvasMock({ + canvasRef, + items, + variablesById, + selectedItemId, + draggingItemId, + onCardPointerDown, + onCardPointerMove, + onCardPointerUp, +}: { + canvasRef: RefObject; + items: MapItem[]; + variablesById: Map; + selectedItemId: string | null; + draggingItemId?: string; + onCardPointerDown: (event: PointerEvent, item: MapItem) => void; + onCardPointerMove: (event: PointerEvent) => void; + onCardPointerUp: (event: PointerEvent) => void; +}) { + return ( +
+
+ Vista aérea da instalação + + {items.length === 0 && ( +
+

+ Arraste variáveis para a imagem +

+

+ Os cartões ficam presos ao ponto exato da imagem. +

+
+ )} + + {items.map((item) => { + const variable = variablesById.get(item.variableId); + + if (!variable) { + return null; + } + + return ( + + ); + })} + +
+ + + +
+ +
+ 100% +
+
+
+ ); +} + +function MapCard({ + item, + variable, + selected, + dragging, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + item: MapItem; + variable: SynopticVariable; + selected: boolean; + dragging: boolean; + onPointerDown: (event: PointerEvent, item: MapItem) => void; + onPointerMove: (event: PointerEvent) => void; + onPointerUp: (event: PointerEvent) => void; +}) { + const Icon = variable.icon; + const isLarge = item.variant === "large"; + + return ( +
onPointerDown(event, item)} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} + className="absolute z-10 cursor-grab touch-none select-none active:cursor-grabbing" + style={{ + left: `${item.x}%`, + top: `${item.y}%`, + transform: "translate(-50%, -100%)", + boxShadow: dragging ? "0 24px 80px rgba(24,184,166,0.26)" : undefined, + }} + > +
+ {selected && ( + <> + {[ + "-top-1 -left-1", + "-top-1 -right-1", + "-bottom-1 -left-1", + "-bottom-1 -right-1", + ].map((position) => ( + + ))} + + )} + +
+
+ + + + +
+ {item.showLabel && ( +

+ {variable.label} +

+ )} + +

+ {formatVariableValue(variable)} +

+
+
+ + {isLarge && ( +
+ {variable.group} + + {variable.connected ? "Online" : "Offline"} + +
+ )} +
+
+ + + + +
+ ); +} + +function PropertiesPanel({ + isDark, + selectedItem, + selectedVariable, + onRemoveSelectedItem, + onVariantChange, + onShowLabelChange, +}: { + isDark: boolean; + selectedItem: MapItem | null; + selectedVariable: SynopticVariable | null; + onRemoveSelectedItem: () => void; + onVariantChange: (variant: CardVariant) => void; + onShowLabelChange: (showLabel: boolean) => void; +}) { + if (!selectedItem || !selectedVariable) { + return ( + + ); + } + + const Icon = selectedVariable.icon; + + return ( + + ); +} + +function PanelSection({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function InputMock({ + label, + value, + isDark, +}: { + label: string; + value: string; + isDark: boolean; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +function clampPercent(value: number) { + return Math.min(96, Math.max(4, value)); +} + +function SegmentedControl({ + value, + options, + onChange, +}: { + value: string; + options: { label: string; value: string }[]; + onChange: (value: string) => void; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +const smallToolClass = + "grid h-9 w-9 place-items-center rounded-[6px] text-[#A8B3C7] transition hover:bg-white/10 hover:text-white"; + +function formatVariableValue(variable: SynopticVariable) { + if (variable.value === null || variable.value === undefined) { + return "—"; + } + + if (typeof variable.value === "number") { + const value = Number.isInteger(variable.value) + ? variable.value.toString() + : variable.value.toFixed(1); + + return `${value} ${variable.unit}`.trim(); + } + + return `${variable.value} ${variable.unit}`.trim(); +} +export default SynopticPage; \ No newline at end of file diff --git a/src/types/weather.ts b/src/types/weather.ts index 5cea0e5..5b08e5f 100644 --- a/src/types/weather.ts +++ b/src/types/weather.ts @@ -1,6 +1,5 @@ export type WeatherForecastResponse = { location: WeatherLocation; - current: WeatherCurrent; daily: WeatherDaily[]; }; @@ -19,19 +18,6 @@ export type WeatherCondition = { code: number; }; -export type WeatherCurrent = { - temperatureC: number; - feelsLikeC: number; - humidity: number; - precipitationMm: number; - windKph: number; - windDegree: number; - windDirection: string; - pressureMb: number; - uv: number; - condition: WeatherCondition; -}; - export type WeatherDaily = { date: string; maxTemperatureC: number; @@ -40,6 +26,11 @@ export type WeatherDaily = { totalPrecipitationMm: number; dailyRainChance: number; maxWindKph: number; + averageWindKph: number; + averageWindDegree: number; + averageWindDirection: string; + averageHumidity: number; + averageVisibilityKm: number; uv: number; sunrise: string; sunset: string;