diff --git a/src/app/App.tsx b/src/app/App.tsx index 0dcc697..3869d76 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,7 +7,7 @@ import { MeteoPage } from "../features/meteo/pages/MeteoPage"; import { ClimateChartsPage } from "../features/climate/pages/ClimateChartsPage"; import { ConsolePage } from "../features/console/pages/ConsolePage"; import { MainChartsPage } from "../features/maincharts/pages/MainChartsPage"; -import { ChartWindowPage } from "../features/maincharts/pages/ChartWindowPage"; +import { ChartWindowPage } from "../features/chartworkspace/pages/ChartWindowPage"; import { SettingsPage } from "../features/settings/pages/SettingsPage"; export type AppPage = diff --git a/src/features/maincharts/components/ChartConfigModal.tsx b/src/features/chartworkspace/components/ChartConfigModal.tsx similarity index 100% rename from src/features/maincharts/components/ChartConfigModal.tsx rename to src/features/chartworkspace/components/ChartConfigModal.tsx diff --git a/src/features/maincharts/hooks/useChartWorkspacePersistence.ts b/src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts similarity index 100% rename from src/features/maincharts/hooks/useChartWorkspacePersistence.ts rename to src/features/chartworkspace/hooks/useChartWorkspacePersistence.ts diff --git a/src/features/maincharts/pages/ChartWindowPage.tsx b/src/features/chartworkspace/pages/ChartWindowPage.tsx similarity index 94% rename from src/features/maincharts/pages/ChartWindowPage.tsx rename to src/features/chartworkspace/pages/ChartWindowPage.tsx index fb966a2..7af07a1 100644 --- a/src/features/maincharts/pages/ChartWindowPage.tsx +++ b/src/features/chartworkspace/pages/ChartWindowPage.tsx @@ -43,6 +43,15 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) { const parts = window.location.pathname.split("/").filter(Boolean); const chartId = parts[parts.length - 1] ?? ""; + const params = new URLSearchParams(window.location.search); + + const scope = + (params.get("scope") as "GLOBAL" | "CLIMATE") ?? "GLOBAL"; + + const channel = + (params.get("channel") as "maincharts" | "climatecharts") ?? + "maincharts"; + const { chartableVariables, connected } = useTelemetryCatalog(); const [charts, setCharts] = useState([]); @@ -64,7 +73,7 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) { : "grid h-9 w-11 place-items-center text-slate-500 transition hover:bg-red-500 hover:text-white"; useChartWorkspacePersistence({ - scope: "GLOBAL", + scope, layoutMode: "fourGrid", charts, onLoaded: (workspace) => { @@ -76,28 +85,31 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) { }, }); - const chart = charts.find((item) => item.id === chartId) ?? null; + const chart = useMemo( + () => charts.find((item) => item.id === chartId) ?? null, + [charts, chartId], + ); useEffect(() => { latestChartRef.current = chart; }, [chart]); const emitMainUpdate = async (patch: Partial) => { - await emit("maincharts://update-chart", { + await emit(`${channel}://update-chart`, { chartId, patch, }); }; const emitMainHidden = async () => { - await emit("maincharts://hide-chart", { + await emit(`${channel}://hide-chart`, { chartId, chart: latestChartRef.current, }); }; const emitMainAttached = async () => { - await emit("maincharts://attach-chart", { + await emit(`${channel}://attach-chart`, { chartId, chart: latestChartRef.current, }); @@ -194,6 +206,10 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) { await currentWindow.destroy(); }; + if (!chart && charts.length === 0) { + return null; + } + if (!chart) { return (
{ console.error("Failed to replace chart in main window", error); diff --git a/src/features/maincharts/utils/openChartWindow.ts b/src/features/chartworkspace/utils/openChartWindow.ts similarity index 81% rename from src/features/maincharts/utils/openChartWindow.ts rename to src/features/chartworkspace/utils/openChartWindow.ts index 1083df1..776e37c 100644 --- a/src/features/maincharts/utils/openChartWindow.ts +++ b/src/features/chartworkspace/utils/openChartWindow.ts @@ -4,8 +4,10 @@ export async function openChartWindow( chartId: string, theme: "dark" | "light", title: string, + scope: "GLOBAL" | "CLIMATE" = "GLOBAL", + channel: "maincharts" | "climatecharts" = "maincharts", ) { - const label = `chart-${chartId}`; + const label = `${channel}-${chartId}`; const existing = await WebviewWindow.getByLabel(label); @@ -16,13 +18,12 @@ export async function openChartWindow( } const chartWindow = new WebviewWindow(label, { - url: `/chart-window/${chartId}?theme=${theme}`, + url: `/chart-window/${chartId}?theme=${theme}&scope=${scope}&channel=${channel}`, title, width: 920, height: 680, - minWidth: 720, minHeight: 480, diff --git a/src/features/climate/hooks/useAccumulatedClimateHistory.ts b/src/features/climate/hooks/useAccumulatedClimateHistory.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/climate/hooks/useClimateChartCatalog.ts b/src/features/climate/hooks/useClimateChartCatalog.ts new file mode 100644 index 0000000..25e8a82 --- /dev/null +++ b/src/features/climate/hooks/useClimateChartCatalog.ts @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import type { ChartVariable } from "../../telemetry/types/telemetryCatalog"; +import { useClimateHistory } from "./useClimateHistory"; + +export function useClimateChartCatalog() { + const climate = useClimateHistory(); + + const chartableVariables = useMemo( + () => + climate.sensors.map((sensor) => ({ + sensorId: sensor.sensorId, + key: sensor.key, + label: sensor.name, + value: + typeof sensor.value === "number" || + typeof sensor.value === "string" || + typeof sensor.value === "boolean" || + sensor.value === null + ? sensor.value + : null, + unit: sensor.unit ?? "", + timestamp: sensor.timestamp, + category: "Clima", + group: "Climate", + chartable: true, + })), + [climate.sensors], + ); + + return { + chartableVariables, + connected: climate.connected, + loading: !climate.module, + sensorCount: climate.sensorCount, + lastTimestamp: climate.lastTimestamp, + }; +} \ No newline at end of file diff --git a/src/features/climate/hooks/useClimateChartSeries.ts b/src/features/climate/hooks/useClimateChartSeries.ts new file mode 100644 index 0000000..a947e1b --- /dev/null +++ b/src/features/climate/hooks/useClimateChartSeries.ts @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + WorkspaceChartInterval, + WorkspaceChartPoint, + WorkspaceChartTimeRange, +} from "../../../components/charts/WorkspaceChart"; + +const BACKEND_URL = "http://localhost:18450"; + +type HistorianPoint = { + timestamp: string; + numericValue: number | null; + booleanValue: boolean | null; + textValue: string | null; +}; + +export function useClimateChartSeries( + sensorKeys: string[], + timeRange: WorkspaceChartTimeRange, + interval: WorkspaceChartInterval, +) { + const [seriesByKey, setSeriesByKey] = useState>({}); + const [loading, setLoading] = useState(true); + const [initialized, setInitialized] = useState(false); + + const keySignature = useMemo( + () => sensorKeys.slice().sort().join(","), + [sensorKeys], + ); + + useEffect(() => { + if (sensorKeys.length === 0) { + setSeriesByKey({}); + setLoading(false); + return; + } + + const controller = new AbortController(); + + async function loadHistory() { + try { + const to = new Date(); + const from = new Date(to.getTime() - rangeToMs(timeRange)); + + if (!initialized) { + setLoading(true); + } + + 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 climate history for ${key}`); + } + + const payload = (await response.json()) as HistorianPoint[]; + + const points = payload + .filter( + (point) => + point.numericValue !== null && + Number.isFinite(point.numericValue), + ) + .map((point) => ({ + timestamp: point.timestamp, + value: point.numericValue as number, + })); + + return [key, aggregatePoints(points, interval)] as const; + }), + ); + + setSeriesByKey(Object.fromEntries(entries)); + setInitialized(true); + } catch (error) { + if (controller.signal.aborted) return; + + console.error("Failed to load climate chart history", error); + + if (!initialized) { + setSeriesByKey({}); + } + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + void loadHistory(); + + const intervalId = window.setInterval(() => { + void loadHistory(); + }, 10000); + + return () => { + controller.abort(); + window.clearInterval(intervalId); + }; + }, [keySignature, timeRange, interval, initialized]); + + return { + seriesByKey, + loading, + }; +} + +function rangeToMs(range: WorkspaceChartTimeRange) { + switch (range) { + case "15m": + return 15 * 60 * 1000; + case "1h": + return 60 * 60 * 1000; + case "6h": + return 6 * 60 * 60 * 1000; + case "24h": + return 24 * 60 * 60 * 1000; + case "7d": + return 7 * 24 * 60 * 60 * 1000; + case "30d": + return 30 * 24 * 60 * 60 * 1000; + } +} + +function intervalToMs(interval: WorkspaceChartInterval) { + switch (interval) { + case "1m": + return 60 * 1000; + case "5m": + return 5 * 60 * 1000; + case "15m": + return 15 * 60 * 1000; + case "1h": + return 60 * 60 * 1000; + } +} + +function aggregatePoints( + points: WorkspaceChartPoint[], + interval: WorkspaceChartInterval, +): WorkspaceChartPoint[] { + const bucketMs = intervalToMs(interval); + + if (bucketMs === 0) { + return points; + } + + const buckets = new Map(); + + for (const point of points) { + const time = new Date(point.timestamp).getTime(); + + if (!Number.isFinite(time)) { + continue; + } + + const bucketTime = Math.floor(time / bucketMs) * bucketMs; + + const values = buckets.get(bucketTime) ?? []; + + if (point.value !== null && Number.isFinite(point.value)) { + values.push(point.value); + } + + buckets.set(bucketTime, values); + } + + return Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([bucketTime, values]) => ({ + timestamp: new Date(bucketTime).toISOString(), + value: average(values), + })); +} + +function average(values: number[]) { + return values.reduce((sum, value) => sum + value, 0) / values.length; +} \ No newline at end of file diff --git a/src/features/climate/hooks/useClimateHistory.ts b/src/features/climate/hooks/useClimateHistory.ts new file mode 100644 index 0000000..07bfe13 --- /dev/null +++ b/src/features/climate/hooks/useClimateHistory.ts @@ -0,0 +1,80 @@ +import { useEffect, useMemo, useState } from "react"; + +import { + useClimateModuleStream, + type ClimateModuleResponse, +} from "./useClimateModuleStream"; + +export type ClimateHistoryPoint = { + timestamp: string; + value: number; +}; + +export type ClimateHistoryByKey = Record; + +const MAX_POINTS_PER_SENSOR = 2000; + +export function useClimateHistory() { + const climate = useClimateModuleStream(); + + const [historyByKey, setHistoryByKey] = useState({}); + + useEffect(() => { + const module = climate.module; + + if (!module) return; + + setHistoryByKey((current) => + appendClimateSnapshot(current, module), + ); + }, [climate.module]); + + const numericSensors = useMemo( + () => + climate.sensors.filter( + (sensor) => + typeof sensor.value === "number" && + Number.isFinite(sensor.value), + ), + [climate.sensors], + ); + + return { + ...climate, + sensors: numericSensors, + historyByKey, + }; +} + +function appendClimateSnapshot( + current: ClimateHistoryByKey, + snapshot: ClimateModuleResponse, +): ClimateHistoryByKey { + const timestamp = snapshot.timestamp ?? new Date().toISOString(); + const next: ClimateHistoryByKey = { ...current }; + + for (const sensor of snapshot.sensors) { + if (typeof sensor.value !== "number" || !Number.isFinite(sensor.value)) { + continue; + } + + const key = sensor.key; + const currentSeries = next[key] ?? []; + + const lastPoint = currentSeries[currentSeries.length - 1]; + + if (lastPoint?.timestamp === timestamp) { + continue; + } + + next[key] = [ + ...currentSeries, + { + timestamp, + value: sensor.value, + }, + ].slice(-MAX_POINTS_PER_SENSOR); + } + + return next; +} \ No newline at end of file diff --git a/src/features/climate/hooks/useClimateModuleStream.ts b/src/features/climate/hooks/useClimateModuleStream.ts new file mode 100644 index 0000000..47cae83 --- /dev/null +++ b/src/features/climate/hooks/useClimateModuleStream.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { Client } from "@stomp/stompjs"; + +import type { ModuleSensorResponse } from "../../../types/meteo"; + +export type ClimateModuleResponse = { + timestamp: string; + sensors: ModuleSensorResponse[]; +}; + +const WS_URL = "ws://localhost:18450/ws"; +const TOPIC = "/topic/modules/climate/latest"; + +export function useClimateModuleStream() { + const [module, setModule] = useState(null); + const [connected, setConnected] = useState(false); + const [lastTimestamp, setLastTimestamp] = useState(null); + + useEffect(() => { + const client = new Client({ + brokerURL: WS_URL, + reconnectDelay: 3000, + + onConnect: () => { + setConnected(true); + + client.subscribe(TOPIC, (message) => { + const payload = JSON.parse(message.body) as ClimateModuleResponse; + + setModule(payload); + setLastTimestamp(payload.timestamp); + }); + }, + + onWebSocketClose: () => { + setConnected(false); + }, + + onStompError: (frame) => { + console.error("Climate module STOMP error", frame); + setConnected(false); + }, + }); + + client.activate(); + + return () => { + client.deactivate(); + }; + }, []); + + return { + module, + sensors: module?.sensors ?? [], + sensorCount: module?.sensors.length ?? 0, + connected, + lastTimestamp, + }; +} \ No newline at end of file diff --git a/src/features/climate/pages/ClimateChartsPage.tsx b/src/features/climate/pages/ClimateChartsPage.tsx index 78557bd..732586e 100644 --- a/src/features/climate/pages/ClimateChartsPage.tsx +++ b/src/features/climate/pages/ClimateChartsPage.tsx @@ -1,360 +1,1650 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { ChartConfigModal } from "../../chartworkspace/components/ChartConfigModal"; +import { openChartWindow } from "../../chartworkspace/utils/openChartWindow"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { + Cog, + Copy, + Maximize2, + AreaChart, BarChart3, ChevronDown, + Columns2, + GripVertical, Grid2X2, - LayoutGrid, - MoreVertical, - Square, - Thermometer, - Waves, - Wind, + LineChart, + PanelTop, + Play, + Plus, + Rows2, + Search, + X, + Trash2 } from "lucide-react"; + import { WorkspaceChart, type WorkspaceChartConfig, type WorkspaceChartMode, + type WorkspaceChartTimeRange, + type WorkspaceChartInterval, } from "../../../components/charts/WorkspaceChart"; +import type { ChartVariable } from "../../telemetry/types/telemetryCatalog"; +import { useClimateChartCatalog } from "../hooks/useClimateChartCatalog"; +import { useClimateChartSeries } from "../hooks/useClimateChartSeries"; + +import { + useChartWorkspacePersistence, + type PersistedChartWorkspaceItem, + type ChartLayoutMode, +} from "../../chartworkspace/hooks/useChartWorkspacePersistence"; + type ClimateChartsPageProps = { theme: "dark" | "light"; }; -type ChartWindow = WorkspaceChartConfig & { - detached: boolean; - position: { x: number; y: number }; +type ChartWorkspaceItem = PersistedChartWorkspaceItem & { + hidden?: boolean; + collapsed?: boolean; + id: string; + title: string; + subtitle: string; + mode: WorkspaceChartMode; + selectedSensorKeys: string[]; + hiddenSensorKeys?: string[]; + timeRange: WorkspaceChartTimeRange; + interval: WorkspaceChartInterval; + detached?: boolean; + windowX?: number; + windowY?: number; + windowWidth?: number; + windowHeight?: number; + windowZIndex?: number; }; -const RADIUS = "rounded-[5px]"; +const RADIUS = "rounded-[6px]"; +const MAX_CHARTS = 10; +const MAX_VARIABLES_PER_CHART = 6; + +const INITIAL_CHARTS: ChartWorkspaceItem[] = []; export function ClimateChartsPage({ theme }: ClimateChartsPageProps) { const isDark = theme === "dark"; - const [charts, setCharts] = useState([ - { - id: "humidity-temperature", - title: "Temperatura / Humidade", - subtitle: "Clima - Estufa Principal", - sourceLabel: "Estufa Principal", - periodLabel: "24H", - icon: Thermometer, - status: "online", - mode: "line", - variables: [ - { - key: "temperature", - label: "Temperatura", - unit: "°C", - color: "#f97316", - visible: true, - data: [18, 18.4, 19, 20.2, 21, 22.4, 24, 25.1, 25.6, 24.9, 23.6, 22], - }, - { - key: "humidity", - label: "Humidade", - unit: "%", - color: "#0ea5e9", - visible: true, - data: [82, 80, 78, 75, 72, 69, 66, 64, 63, 66, 70, 75], - }, - ], - detached: false, - position: { x: 360, y: 140 }, - }, - { - id: "ventilation", - title: "Ventilação", - subtitle: "Ventiladores e janelas", - sourceLabel: "Zona Norte", - periodLabel: "24H", - icon: Wind, - status: "online", - mode: "area", - variables: [ - { - key: "fanSpeed", - label: "Velocidade Ventilação", - unit: "%", - color: "#22c55e", - visible: true, - data: [10, 12, 18, 24, 35, 48, 62, 70, 68, 51, 32, 18], - }, - { - key: "windowOpening", - label: "Abertura Janelas", - unit: "%", - color: "#a78bfa", - visible: true, - data: [0, 0, 5, 12, 25, 40, 58, 65, 61, 45, 20, 5], - }, - ], - detached: false, - position: { x: 460, y: 180 }, - }, - { - id: "lighting", - title: "Iluminação", - subtitle: "Ciclo de luz", - sourceLabel: "Setor 1", - periodLabel: "24H", - icon: BarChart3, - status: "online", - mode: "bar", - variables: [ - { - key: "lux", - label: "Luminosidade", - unit: "lux", - color: "#facc15", - visible: true, - data: [0, 0, 120, 450, 800, 1100, 1200, 980, 600, 220, 0, 0], - }, - ], - detached: false, - position: { x: 520, y: 220 }, - }, - { - id: "pressure-flow-example", - title: "Pressão / Fluxo", - subtitle: "Linha técnica", - sourceLabel: "Linha Principal", - periodLabel: "24H", - icon: Waves, - status: "online", - mode: "line", - variables: [ - { - key: "pressure", - label: "Pressão", - unit: "bar", - color: "#4ade80", - visible: true, - data: [2.8, 3, 3.1, 3.15, 3.08, 3.05, 2.95, 2.86, 2.92, 3.0, 3.15, 2.96], - }, - { - key: "flow", - label: "Caudal", - unit: "m³/h", - color: "#38bdf8", - visible: false, - data: [2, 2.4, 2.7, 3.1, 18, 20, 13, 16, 18, 15, 23, 5], - }, - ], - detached: false, - position: { x: 600, y: 260 }, - }, - ]); + const { chartableVariables, connected } = useClimateChartCatalog(); - const updateChart = (id: string, update: Partial) => { + const [layoutMode, setLayoutMode] = useState("twoColumns"); + const [charts, setCharts] = useState(INITIAL_CHARTS); + const [configChartId, setConfigChartId] = useState(null); + const [savedSearch, setSavedSearch] = useState(""); + const [savedOpen, setSavedOpen] = useState(false); + const [movingChartId, setMovingChartId] = useState(null); + const [newChartOpen, setNewChartOpen] = useState(false); + const [placingChartId, setPlacingChartId] = useState(null); + + const changeLayoutMode = (nextLayoutMode: ChartLayoutMode) => { + const nextVisibleCount = getVisibleSlotCount(nextLayoutMode); + + setCharts((current) => { + let openSeen = 0; + + return current.map((chart) => { + if (chart.hidden) { + return chart; + } + + openSeen += 1; + + return { + ...chart, + collapsed: openSeen > nextVisibleCount, + }; + }); + }); + + setLayoutMode(nextLayoutMode); + setPlacingChartId(null); + setMovingChartId(null); + }; + + const workspacePersistence = useChartWorkspacePersistence({ + scope: "CLIMATE", + layoutMode, + charts, + onLoaded: (workspace) => { + setLayoutMode(workspace.layoutMode); + setCharts(workspace.charts); + }, + }); + + const canAddMoreCharts = charts.length < MAX_CHARTS; + + const visibleCharts = useMemo(() => { + const openCharts = charts.filter( + (chart) => !chart.hidden && !chart.collapsed && !chart.detached, + ); + + if (layoutMode === "single") return openCharts.slice(0, 1); + if (layoutMode === "twoColumns") return openCharts.slice(0, 2); + if (layoutMode === "twoRows") return openCharts.slice(0, 2); + return openCharts.slice(0, 4); + }, [charts, layoutMode]); + + const savedCharts = useMemo( + () => + charts.filter((chart) => + chart.title.toLowerCase().includes(savedSearch.toLowerCase()), + ), + [charts, savedSearch], + ); + + const configChart = charts.find((chart) => chart.id === configChartId) ?? null; + + const swapCharts = (sourceId: string, targetId: string) => { + if (sourceId === targetId) { + setMovingChartId(null); + return; + } + + setCharts((current) => { + const sourceIndex = current.findIndex((chart) => chart.id === sourceId); + const targetIndex = current.findIndex((chart) => chart.id === targetId); + + if (sourceIndex === -1 || targetIndex === -1) return current; + + const next = [...current]; + [next[sourceIndex], next[targetIndex]] = [ + next[targetIndex], + next[sourceIndex], + ]; + + return next; + }); + + setMovingChartId(null); + }; + + const addChart = ({ + title, + mode, + selectedSensorKeys, + }: { + title: string; + mode: WorkspaceChartMode; + selectedSensorKeys: string[]; + }) => { + if (!canAddMoreCharts) return; + + const newChartId = `chart-${Date.now()}`; + + setCharts((current) => { + const visibleSlotCount = getVisibleSlotCount(layoutMode); + const visibleOpenCount = current.filter( + (chart) => !chart.hidden && !chart.collapsed && !chart.detached + ).length; + + const shouldAskPlacement = visibleOpenCount >= visibleSlotCount; + + const newChart: ChartWorkspaceItem = { + id: newChartId, + title, + subtitle: "Gráfico personalizado de telemetria.", + mode, + selectedSensorKeys, + timeRange: "24h", + interval: "5m", + hidden: false, + collapsed: shouldAskPlacement, + }; + + if (shouldAskPlacement) { + setPlacingChartId(newChartId); + } + + return [...current, newChart]; + }); + + setNewChartOpen(false); + }; + + const duplicateChart = (chartId: string) => { + if (!canAddMoreCharts) return; + + const chart = charts.find((item) => item.id === chartId); + if (!chart) return; + + const copy: ChartWorkspaceItem = { + ...chart, + id: `chart-${Date.now()}`, + title: `${chart.title} cópia`, + }; + + setCharts((current) => { + const next = [...current, copy]; + + if (next.length >= 3) { + setLayoutMode("fourGrid"); + } else if (next.length === 2) { + setLayoutMode("twoColumns"); + } + + return next; + }); + }; + + const setChartMode = (chartId: string, mode: WorkspaceChartMode) => { setCharts((current) => current.map((chart) => - chart.id === id ? { ...chart, ...update } : chart, + chart.id === chartId ? { ...chart, mode } : chart, ), ); }; - const updateChartMode = (id: string, mode: WorkspaceChartMode) => { - updateChart(id, { mode }); + const toggleVariable = (chartId: string, key: string) => { + setCharts((current) => + current.map((chart) => { + if (chart.id !== chartId) return chart; + + const hiddenSensorKeys = chart.hiddenSensorKeys ?? []; + const isHidden = hiddenSensorKeys.includes(key); + + return { + ...chart, + hiddenSensorKeys: isHidden + ? hiddenSensorKeys.filter((item) => item !== key) + : [...hiddenSensorKeys, key], + }; + }), + ); }; - const toggleVariable = (id: string, variableKey: string) => { + const loadSavedChart = (chartId: string) => { + setCharts((current) => { + const selected = current.find((chart) => chart.id === chartId); + if (!selected) return current; + + const remaining = current.filter((chart) => chart.id !== chartId); + + return [ + { ...selected, hidden: false, collapsed: false }, + ...remaining, + ]; + }); + + setSavedOpen(false); + setPlacingChartId(null); + }; + + const closeChart = async (chartId: string) => { + await closeDetachedChartWindow(chartId); + setCharts((current) => current.map((chart) => - chart.id !== id - ? chart - : { + chart.id === chartId + ? { ...chart, - variables: chart.variables.map((variable) => - variable.key === variableKey - ? { ...variable, visible: !variable.visible } - : variable, - ), - }, + detached: false, + hidden: true, + collapsed: false, + } + : chart, + ), + ); + + if (configChartId === chartId) setConfigChartId(null); + if (movingChartId === chartId) setMovingChartId(null); + if (placingChartId === chartId) setPlacingChartId(null); + }; + + const deleteChart = async (chartId: string) => { + await closeDetachedChartWindow(chartId); + + setCharts((current) => current.filter((chart) => chart.id !== chartId)); + + if (configChartId === chartId) setConfigChartId(null); + if (movingChartId === chartId) setMovingChartId(null); + if (placingChartId === chartId) setPlacingChartId(null); + }; + + const placeChartHere = (targetChartId: string) => { + if (!placingChartId || placingChartId === targetChartId) return; + + setCharts((current) => { + const sourceIndex = current.findIndex((chart) => chart.id === placingChartId); + const targetIndex = current.findIndex((chart) => chart.id === targetChartId); + + if (sourceIndex === -1 || targetIndex === -1) return current; + + const next = [...current]; + const source = next[sourceIndex]; + const target = next[targetIndex]; + + next[targetIndex] = { + ...source, + hidden: false, + collapsed: false, + }; + + next[sourceIndex] = { + ...target, + hidden: false, + collapsed: true, + }; + + return next; + }); + + setPlacingChartId(null); + }; + + const openSavedChart = (chartId: string) => { + const chart = charts.find((item) => item.id === chartId); + if (!chart) return; + + const isActuallyOpen = !chart.hidden && !chart.collapsed && !chart.detached; + if (isActuallyOpen) return; + + const visibleSlotCount = getVisibleSlotCount(layoutMode); + const visibleOpenCount = charts.filter( + (item) => !item.hidden && !item.collapsed && !item.detached, + ).length; + + if (visibleOpenCount === 0) { + loadSavedChart(chartId); + return; + } + + if (visibleOpenCount < visibleSlotCount) { + loadSavedChart(chartId); + return; + } + + setPlacingChartId(chartId); + setSavedOpen(false); + }; + + const closeDetachedChartWindow = async (chartId: string) => { + const label = `chart-${chartId}`; + const existing = await WebviewWindow.getByLabel(label); + + if (!existing) return; + + try { + await existing.destroy(); + } catch (error) { + console.error(`Failed to destroy detached chart window: ${label}`, error); + } + }; + + const detachChart = (chartId: string) => { + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + detached: true, + hidden: false, + collapsed: false, + } + : chart, + ), + ); + + window.setTimeout(() => { + const chart = charts.find((item) => item.id === chartId); + + void openChartWindow( + chartId, + theme, + chart?.title ?? "Chart", + "CLIMATE", + "climatecharts", + ); + }, 100); + }; + + const attachChart = async (chartId: string) => { + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + detached: false, + hidden: false, + collapsed: false, + } + : chart, + ), + ); + + await closeDetachedChartWindow(chartId); + }; + + const moveDetachedChart = (chartId: string, x: number, y: number) => { + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { ...chart, windowX: x, windowY: y } + : chart, ), ); }; + useEffect(() => { + const unlistenAttachPromise = listen<{ + chartId: string; + chart: ChartWorkspaceItem | null; + }>("climatecharts://attach-chart", (event) => { + const chartId = event.payload.chartId; + const updatedChart = event.payload.chart; + + setCharts((current) => { + const visibleSlotCount = getVisibleSlotCount(layoutMode); + + const visibleOpenCount = current.filter( + (chart) => + !chart.hidden && + !chart.collapsed && + !chart.detached && + chart.id !== chartId, + ).length; + + const shouldAskPlacement = visibleOpenCount >= visibleSlotCount; + + if (shouldAskPlacement) { + setPlacingChartId(chartId); + } + + return current.map((chart) => + chart.id === chartId + ? { + ...chart, + ...(updatedChart ?? {}), + detached: false, + hidden: false, + collapsed: shouldAskPlacement, + } + : chart, + ); + }); + }); + + const unlistenHidePromise = listen<{ + chartId?: string; + chart?: ChartWorkspaceItem | null; + }>("climatecharts://hide-chart", (event) => { + const chartId = event.payload.chartId ?? event.payload.chart?.id; + if (!chartId) return; + + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + ...(event.payload.chart ?? {}), + detached: false, + hidden: true, + collapsed: false, + } + : chart, + ), + ); + + setPlacingChartId((current) => (current === chartId ? null : current)); + }); + + const unlistenUpdatePromise = listen<{ + chartId: string; + patch: Partial; + }>("climatecharts://update-chart", (event) => { + const { chartId, patch } = event.payload; + + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + ...patch, + } + : chart, + ), + ); + }); + + const unlistenReplacePromise = listen<{ chart: ChartWorkspaceItem }>( + "climatecharts://replace-chart", + (event) => { + const updatedChart = event.payload.chart; + + setCharts((current) => + current.map((chart) => + chart.id === updatedChart.id + ? { ...chart, ...updatedChart } + : chart, + ), + ); + }, + ); + + return () => { + void unlistenAttachPromise.then((unlisten) => unlisten()); + void unlistenHidePromise.then((unlisten) => unlisten()); + void unlistenUpdatePromise.then((unlisten) => unlisten()); + void unlistenReplacePromise.then((unlisten) => unlisten()); + }; + }, [layoutMode]); + return ( -
-
-
-

- Área de trabalho de gráficos -

+
+
+
+ } + title="1 gráfico" + onClick={() => changeLayoutMode("single")} + /> - + } + title="2 lado a lado" + onClick={() => changeLayoutMode("twoColumns")} + /> + + } + title="2 vertical" + onClick={() => changeLayoutMode("twoRows")} + /> + + } + title="4 gráficos" + onClick={() => changeLayoutMode("fourGrid")} + />
-
- - - - - - - + + {savedOpen && ( + { + setSavedOpen(false); + setPlacingChartId(null); + }} + onStartPlacement={openSavedChart} + onDelete={deleteChart} + /> + )} +
+ +
-
- {charts - .filter((chart) => !chart.detached) - .map((chart) => ( - { + if (canAddMoreCharts) setNewChartOpen(true); + }} + onOpenSaved={() => setSavedOpen(true)} + /> + ) : ( +
+ {visibleCharts.map((chartItem) => ( + updateChartMode(chart.id, mode)} - onVariableToggle={(variableKey) => - toggleVariable(chart.id, variableKey) - } - onDetach={() => updateChart(chart.id, { detached: true })} - onAttach={() => updateChart(chart.id, { detached: false })} + chartItem={chartItem} + chartableVariables={chartableVariables} + connected={connected} + movingChartId={movingChartId} + setMovingChartId={setMovingChartId} + swapCharts={swapCharts} + duplicateChart={duplicateChart} + closeChart={closeChart} + setConfigChartId={setConfigChartId} + setChartMode={setChartMode} + toggleVariable={toggleVariable} + setCharts={setCharts} + placingChartId={placingChartId} + placeChartHere={placeChartHere} + detachChart={detachChart} + attachChart={attachChart} + moveDetachedChart={moveDetachedChart} /> ))} -
- {charts - .filter((chart) => chart.detached) - .map((chart) => ( - updateChart(chart.id, { position })} - onAttach={() => updateChart(chart.id, { detached: false })} - onModeChange={(mode) => updateChartMode(chart.id, mode)} - onVariableToggle={(variableKey) => - toggleVariable(chart.id, variableKey) - } - /> - ))} + {placingChartId && visibleCharts.length < getVisibleSlotCount(layoutMode) && ( + + )} + + )} + + {newChartOpen && ( + setNewChartOpen(false)} + onCreate={addChart} + /> + )} + + {configChart && ( + setConfigChartId(null)} + onSave={(updatedChart) => { + setCharts((current) => + current.map((chart) => + chart.id === updatedChart.id + ? { ...chart, ...updatedChart } + : chart, + ), + ); + + setConfigChartId(null); + }} + /> + )}
); } -function DetachedChartWindow({ +function WorkspaceChartContainer({ theme, - chart, - onMove, - onAttach, - onModeChange, - onVariableToggle, + chartItem, + chartableVariables, + connected, + movingChartId, + setMovingChartId, + swapCharts, + duplicateChart, + closeChart, + setConfigChartId, + setChartMode, + toggleVariable, + setCharts, + placingChartId, + placeChartHere, + detachChart, + attachChart, + moveDetachedChart }: { theme: "dark" | "light"; - chart: ChartWindow; - onMove: (position: { x: number; y: number }) => void; - onAttach: () => void; - onModeChange: (mode: WorkspaceChartMode) => void; - onVariableToggle: (variableKey: string) => void; + chartItem: ChartWorkspaceItem; + chartableVariables: ChartVariable[]; + connected: boolean; + movingChartId: string | null; + setMovingChartId: React.Dispatch>; + swapCharts: (sourceId: string, targetId: string) => void; + duplicateChart: (chartId: string) => void; + closeChart: (chartId: string) => void | Promise; + setConfigChartId: React.Dispatch>; + setChartMode: (chartId: string, mode: WorkspaceChartMode) => void; + toggleVariable: (chartId: string, key: string) => void; + setCharts: React.Dispatch>; + placingChartId: string | null; + placeChartHere: (targetChartId: string) => void; + detachChart: (chartId: string) => void; + attachChart: (chartId: string) => void | Promise; + moveDetachedChart: (chartId: string, x: number, y: number) => void; }) { - const width = 980; + const isMoving = movingChartId === chartItem.id; - const startDrag = (event: React.PointerEvent) => { - event.preventDefault(); + const canReceiveMove = + (movingChartId !== null && movingChartId !== chartItem.id) || + (placingChartId !== null && placingChartId !== chartItem.id); - const startX = event.clientX; - const startY = event.clientY; - const initial = chart.position; + const { seriesByKey, loading } = useClimateChartSeries( + chartItem.selectedSensorKeys, + chartItem.timeRange, + chartItem.interval, + ); - const handleMove = (moveEvent: PointerEvent) => { - const nextX = initial.x + moveEvent.clientX - startX; - const nextY = initial.y + moveEvent.clientY - startY; + const selectedVariables = useMemo( + () => + chartableVariables.filter((variable) => + chartItem.selectedSensorKeys.includes(variable.key), + ), + [ + chartItem.selectedSensorKeys.join("|"), + chartableVariables.map((variable) => + `${variable.key}:${variable.label}:${variable.unit}`, + ).join("|"), + ], + ); - onMove({ - x: nextX, - y: Math.min(Math.max(0, nextY), window.innerHeight - 72), - }); - }; + const variablesStillResolving = + chartItem.selectedSensorKeys.length > 0 && + selectedVariables.length === 0; - const handleUp = () => { - window.removeEventListener("pointermove", handleMove); - window.removeEventListener("pointerup", handleUp); - }; - - window.addEventListener("pointermove", handleMove); - window.addEventListener("pointerup", handleUp); + const chartConfig: WorkspaceChartConfig = { + id: chartItem.id, + title: chartItem.title, + subtitle: + chartItem.selectedSensorKeys.length > 0 + ? `${chartItem.selectedSensorKeys.length} variáveis selecionadas` + : chartItem.subtitle, + icon: BarChart3, + status: connected ? "online" : "offline", + sourceLabel: connected ? "Telemetry" : "Offline", + mode: chartItem.mode, + timeRange: chartItem.timeRange, + interval: chartItem.interval, + variables: selectedVariables.map((variable, index) => ({ + key: variable.key, + label: variable.label, + unit: variable.unit, + color: getVariableColor(index), + data: seriesByKey[variable.key] ?? [], + visible: !(chartItem.hiddenSensorKeys ?? []).includes(variable.key), + })), }; return (
{ + if (movingChartId && movingChartId !== chartItem.id) { + swapCharts(movingChartId, chartItem.id); + } }} + className={ + canReceiveMove + ? "relative rounded-[7px] ring-2 ring-[#4FD1C5]/50 transition" + : isMoving + ? "relative rounded-[7px] opacity-60 ring-2 ring-[#4FD1C5]" + : "relative rounded-[7px]" + } > +
+ + + + + + + +
+ + {canReceiveMove && ( +
+ +
+ )} + detachChart(chartItem.id)} + onAttach={() => attachChart(chartItem.id)} + onTimeRangeChange={(range) => + setCharts((current) => + current.map((chart) => + chart.id === chartItem.id + ? { ...chart, timeRange: range } + : chart, + ), + ) + } + onIntervalChange={(interval) => + setCharts((current) => + current.map((chart) => + chart.id === chartItem.id + ? { ...chart, interval } + : chart, + ), + ) + } + dragHandle={ + + } + onModeChange={(mode) => + setChartMode(chartItem.id, mode) + } + onVariableToggle={(variableKey) => + toggleVariable(chartItem.id, variableKey) + } + onHeaderPointerDown={ + chartItem.detached + ? (event) => { + const startX = event.clientX; + const startY = event.clientY; + const initialX = chartItem.windowX ?? 120; + const initialY = chartItem.windowY ?? 120; + + const handlePointerMove = (moveEvent: PointerEvent) => { + moveDetachedChart( + chartItem.id, + initialX + moveEvent.clientX - startX, + initialY + moveEvent.clientY - startY, + ); + }; + + const handlePointerUp = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + } + : undefined + } />
); } -function ToolbarButton({ - icon: Icon, - active, +function floatingIconClass(theme: "dark" | "light") { + return theme === "dark" + ? `${RADIUS} grid h-8 w-8 place-items-center border border-[#263247] bg-[#111A2B] text-[#A8B3C7] transition hover:text-white` + : `${RADIUS} grid h-8 w-8 place-items-center border border-[#D7DEE8] bg-white text-slate-500 transition hover:text-[#0F172A]`; +} + +function SavedChartsDropdown({ theme, + charts, + search, + onSearchChange, + onClose, + onStartPlacement, + onDelete, }: { - icon: React.ElementType; - active?: boolean; theme: "dark" | "light"; + charts: ChartWorkspaceItem[]; + search: string; + onSearchChange: (value: string) => void; + onClose: () => void; + onStartPlacement: (chartId: string) => void; + onDelete: (chartId: string) => void | Promise; +}) { + const isDark = theme === "dark"; + + return ( +
+
+
+

Gráficos Guardados

+

+ {charts.length}/10 gráficos no workspace. +

+
+ + +
+ +
+ + onSearchChange(event.target.value)} + placeholder="Pesquisar gráficos..." + className="w-full bg-transparent outline-none placeholder:text-inherit" + /> +
+ +
+ {charts.map((chart) => ( +
+
+

{chart.title}

+

+ {chart.selectedSensorKeys.length} variáveis · {chart.timeRange} · {chart.interval} +

+
+ +
+ {chart.detached && !chart.hidden ? ( + + Destacado + + ) : chart.hidden || chart.collapsed ? ( + + ) : ( + + Aberto + + )} + + +
+
+ ))} + + {charts.length === 0 && ( +
+ Nenhum gráfico guardado. +
+ )} +
+
+ ); +} + +function NewChartModal({ + theme, + variables, + chartNumber, + onClose, + onCreate, +}: { + theme: "dark" | "light"; + variables: ChartVariable[]; + chartNumber: number; + onClose: () => void; + onCreate: (chart: { + title: string; + mode: WorkspaceChartMode; + selectedSensorKeys: string[]; + }) => void; +}) { + const isDark = theme === "dark"; + const [title, setTitle] = useState(`Novo Gráfico ${chartNumber}`); + const [mode, setMode] = useState("line"); + const [search, setSearch] = useState(""); + const [selectedSensorKeys, setSelectedSensorKeys] = useState([]); + + const filteredVariables = variables.filter((variable) => + `${variable.label} ${variable.category} ${variable.group} ${variable.unit}` + .toLowerCase() + .includes(search.toLowerCase()), + ); + + const toggleVariable = (key: string) => { + setSelectedSensorKeys((current) => { + const alreadySelected = current.includes(key); + + if (alreadySelected) { + return current.filter((item) => item !== key); + } + + if (current.length >= MAX_VARIABLES_PER_CHART) { + return current; + } + + return [...current, key]; + }); + }; + + const canCreate = title.trim().length > 0 && selectedSensorKeys.length > 0; + + return ( +
+
+
+
+

+ Novo gráfico +

+ +

Criar gráfico

+
+ + +
+ +
+
+
+ + setTitle(event.target.value)} + className={ + isDark + ? `${RADIUS} h-11 w-full border border-[#263247] bg-[#07101B] px-3 text-sm font-bold text-white outline-none` + : `${RADIUS} h-11 w-full border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm font-bold text-[#0F172A] outline-none` + } + /> +
+ +
+ + +
+ } + label="Linha" + onClick={() => setMode("line")} + /> + } + label="Área" + onClick={() => setMode("area")} + /> + } + label="Barras" + onClick={() => setMode("bar")} + /> +
+
+ +
+

+ Selecionadas +

+ +

+ {selectedSensorKeys.length}/{MAX_VARIABLES_PER_CHART} +

+
+
+ +
+ + +
+ + setSearch(event.target.value)} + placeholder="Pesquisar variável..." + className="w-full bg-transparent outline-none placeholder:text-inherit" + /> +
+ +
+ {filteredVariables.map((variable, index) => { + const active = selectedSensorKeys.includes(variable.key); + + return ( + = MAX_VARIABLES_PER_CHART + } + onClick={() => toggleVariable(variable.key)} + /> + ); + })} + + {filteredVariables.length === 0 && ( + + )} +
+
+
+ +
+ + + +
+
+
+ ); +} + +function VariableRow({ + theme, + variable, + color, + active, + disabled, + onClick, +}: { + theme: "dark" | "light"; + variable: ChartVariable; + color: string; + active: boolean; + disabled: boolean; + onClick: () => void; }) { const isDark = theme === "dark"; return ( ); } +function EmptyVariableList({ theme }: { theme: "dark" | "light" }) { + const isDark = theme === "dark"; + + return ( +
+ Nenhuma variável encontrada. +
+ ); +} + +function layoutGridClass(layoutMode: ChartLayoutMode) { + if (layoutMode === "twoColumns") { + return "grid gap-4 2xl:grid-cols-2"; + } + + if (layoutMode === "fourGrid") { + return "grid gap-4 2xl:grid-cols-2"; + } + + return "grid gap-4"; +} + +function getVisibleSlotCount(layoutMode: ChartLayoutMode) { + if (layoutMode === "single") return 1; + if (layoutMode === "twoColumns") return 2; + if (layoutMode === "twoRows") return 2; + return 4; +} + +function LayoutButton({ + theme, + active, + icon, + title, + onClick, +}: { + theme: "dark" | "light"; + active: boolean; + icon: React.ReactNode; + title: string; + onClick: () => void; +}) { + const isDark = theme === "dark"; + + return ( + + ); +} + +function ModeButton({ + theme, + active, + icon, + label, + onClick, +}: { + theme: "dark" | "light"; + active: boolean; + icon?: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function EmptyWorkspace({ + theme, + canAddMoreCharts, + onAddChart, + onOpenSaved, +}: { + theme: "dark" | "light"; + canAddMoreCharts: boolean; + onAddChart: () => void; + onOpenSaved: () => void; +}) { + const isDark = theme === "dark"; + + return ( +
+
+
+ +
+ +

+ Nenhum gráfico aberto +

+ +

+ Abra um gráfico guardado ou crie um novo gráfico para continuar. +

+ +
+ + + +
+
+
+ ); +} + +function getVariableColor(index: number) { + const colors = [ + "#4FD1C5", + "#3B82F6", + "#FACC15", + "#7DD3FC", + "#A5B4FC", + "#FB7185", + ]; + + return colors[index % colors.length]; +} + export default ClimateChartsPage; \ No newline at end of file diff --git a/src/features/maincharts/pages/MainChartsPage.tsx b/src/features/maincharts/pages/MainChartsPage.tsx index 8f61da9..39c8e73 100644 --- a/src/features/maincharts/pages/MainChartsPage.tsx +++ b/src/features/maincharts/pages/MainChartsPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { listen } from "@tauri-apps/api/event"; -import { ChartConfigModal } from "../components/ChartConfigModal"; -import { openChartWindow } from "../utils/openChartWindow"; +import { ChartConfigModal } from "../../chartworkspace/components/ChartConfigModal"; +import { openChartWindow } from "../../chartworkspace/utils/openChartWindow"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { Cog, @@ -38,7 +38,7 @@ import { useChartWorkspacePersistence, type PersistedChartWorkspaceItem, type ChartLayoutMode, -} from "../hooks/useChartWorkspacePersistence"; +} from "../../chartworkspace/hooks/useChartWorkspacePersistence"; type MainChartsPageProps = { theme: "dark" | "light";