From c54c2c6518726d8066ee57f709de76383fa30ea4 Mon Sep 17 00:00:00 2001 From: litoral05 Date: Thu, 28 May 2026 08:37:00 +0100 Subject: [PATCH] Fix detached chart window close and attach behavior --- src-tauri/capabilities/default.json | 20 +- src/app/App.tsx | 33 +- src/components/charts/WorkspaceChart.tsx | 84 ++- .../components/ChartConfigModal.tsx | 416 +++++++++++++ .../maincharts/pages/ChartWindowPage.tsx | 340 +++++++++++ .../maincharts/pages/MainChartsPage.tsx | 576 ++++++++++-------- .../maincharts/utils/openChartWindow.ts | 49 ++ src/index.css | 16 +- 8 files changed, 1214 insertions(+), 320 deletions(-) create mode 100644 src/features/maincharts/components/ChartConfigModal.tsx create mode 100644 src/features/maincharts/pages/ChartWindowPage.tsx create mode 100644 src/features/maincharts/utils/openChartWindow.ts diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..ddf0074 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,9 +2,23 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main"], + "windows": ["main", "chart-*"], "permissions": [ "core:default", - "opener:default" + "opener:default", + + "core:webview:allow-create-webview-window", + + "core:window:allow-close", + "core:window:allow-destroy", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + + "core:window:allow-minimize", + "core:window:allow-maximize", + "core:window:allow-toggle-maximize", + + "core:window:allow-start-dragging" ] -} +} \ No newline at end of file diff --git a/src/app/App.tsx b/src/app/App.tsx index 493b18d..3444dd1 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,6 +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 { SettingsPage } from "../features/settings/pages/SettingsPage"; export type AppPage = @@ -15,15 +16,11 @@ export type AppPage = | "console" | "maincharts" | "settings" - - // CLIMATE | "climate" | "climateCharts" | "climateLighting" | "climateVentilation" | "climateSynoptic" - - // IRRIGATION | "irrigation" | "irrigationCharts" | "irrigationFilters" @@ -32,35 +29,33 @@ export type AppPage = | "irrigationSynoptic"; function App() { - const [activePage, setActivePage] = - useState("dashboard"); + const [activePage, setActivePage] = useState("dashboard"); + + const isChartWindow = window.location.pathname.startsWith("/chart-window/"); + + if (isChartWindow) { + return ; + } return ( - + {({ theme }) => { - if (activePage === "meteo") { - return ; - } + if (activePage === "meteo") return ; if (activePage === "climateCharts") { return ; } - - if (activePage === "console") { - return ; - } - + if (activePage === "console") return ; + if (activePage === "maincharts") { return ; } if (activePage === "settings") { - return + return ; } + return (
{dragHandle &&
{dragHandle}
} -
-
-

- {chart.title} -

+ {(chart.title || chart.subtitle) && ( +
+ {chart.title ? ( +
+

+ {chart.title} +

- + +
+ ) : null} + + {chart.subtitle ? ( +

+ {chart.subtitle} +

+ ) : null}
- -

- {chart.subtitle} -

-
+ )}
@@ -345,12 +355,22 @@ export function WorkspaceChart({
-
+
@@ -590,7 +610,13 @@ export function WorkspaceChart({ )}
-
+
{visibleVariables.length === 0 && !shouldShowLoading ? ( ) : data.length === 0 && !shouldShowLoading ? ( @@ -693,7 +719,7 @@ export function WorkspaceChart({
{showIndicators && primaryVariable && ( -
+
{ const timestamp = payload?.[0]?.payload?.timestamp; return timestamp ? formatTooltipDate(timestamp) : ""; diff --git a/src/features/maincharts/components/ChartConfigModal.tsx b/src/features/maincharts/components/ChartConfigModal.tsx new file mode 100644 index 0000000..d9cd61e --- /dev/null +++ b/src/features/maincharts/components/ChartConfigModal.tsx @@ -0,0 +1,416 @@ +import { useEffect, useState } from "react"; +import { + AreaChart, + BarChart3, + LineChart, + Search, + X, +} from "lucide-react"; + +import type { + WorkspaceChartMode, + WorkspaceChartTimeRange, + WorkspaceChartInterval, +} from "../../../components/charts/WorkspaceChart"; +import type { ChartVariable } from "../../telemetry/types/telemetryCatalog"; + +export type ChartConfigModalChart = { + id: string; + title: string; + mode: WorkspaceChartMode; + selectedSensorKeys: string[]; + hiddenSensorKeys?: string[]; + timeRange: WorkspaceChartTimeRange; + interval: WorkspaceChartInterval; +}; + +type ChartConfigModalProps = { + theme: "dark" | "light"; + chart: ChartConfigModalChart; + variables: ChartVariable[]; + maxVariables?: number; + onClose?: () => void; + onSave: (chart: ChartConfigModalChart) => void; +}; + +const RADIUS = "rounded-[6px]"; + +export function ChartConfigModal({ + theme, + chart, + variables, + maxVariables = 6, + onClose, + onSave, +}: ChartConfigModalProps) { + const isDark = theme === "dark"; + const [search, setSearch] = useState(""); + const [draft, setDraft] = useState(chart); + + useEffect(() => { + setDraft(chart); + }, [chart.id]); + + const filteredVariables = variables.filter((variable) => + `${variable.label} ${variable.category} ${variable.group} ${variable.unit}` + .toLowerCase() + .includes(search.toLowerCase()), + ); + + const toggleVariable = (key: string) => { + setDraft((current) => { + const alreadySelected = current.selectedSensorKeys.includes(key); + + if (!alreadySelected && current.selectedSensorKeys.length >= maxVariables) { + return current; + } + + const selectedSensorKeys = alreadySelected + ? current.selectedSensorKeys.filter((item) => item !== key) + : [...current.selectedSensorKeys, key]; + + return { + ...current, + selectedSensorKeys, + hiddenSensorKeys: (current.hiddenSensorKeys ?? []).filter( + (item) => item !== key, + ), + }; + }); + }; + + const discardAndClose = () => { + onClose?.(); + }; + + const saveAndClose = () => { + onSave(draft); + }; + + return ( +
+
+
+
+

+ Configuração do gráfico +

+ +

{draft.title}

+
+ + +
+ +
+
+
+ + + setDraft((current) => ({ + ...current, + title: 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={() => + setDraft((current) => ({ + ...current, + mode: "line", + })) + } + /> + } + label="Área" + onClick={() => + setDraft((current) => ({ + ...current, + mode: "area", + })) + } + /> + } + label="Barras" + onClick={() => + setDraft((current) => ({ + ...current, + mode: "bar", + })) + } + /> +
+
+ +
+

+ Selecionadas +

+ +

+ {draft.selectedSensorKeys.length}/{maxVariables} +

+
+
+ +
+
+ + + setSearch(event.target.value)} + placeholder="Pesquisar variável..." + className="w-full bg-transparent outline-none placeholder:text-inherit" + /> +
+ +
+ {filteredVariables.map((variable, index) => { + const active = draft.selectedSensorKeys.includes( + variable.key, + ); + + return ( + = maxVariables + } + 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 ModeButton({ + theme, + active, + icon, + label, + onClick, +}: { + theme: "dark" | "light"; + active: boolean; + icon?: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function getVariableColor(index: number) { + const colors = [ + "#4FD1C5", + "#3B82F6", + "#FACC15", + "#7DD3FC", + "#A5B4FC", + "#FB7185", + ]; + + return colors[index % colors.length]; +} \ No newline at end of file diff --git a/src/features/maincharts/pages/ChartWindowPage.tsx b/src/features/maincharts/pages/ChartWindowPage.tsx new file mode 100644 index 0000000..f139947 --- /dev/null +++ b/src/features/maincharts/pages/ChartWindowPage.tsx @@ -0,0 +1,340 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { emit } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { X, Minus, Maximize2, PanelTop, Cog, Square } from "lucide-react"; +import { ChartConfigModal } from "../components/ChartConfigModal"; + +import { + WorkspaceChart, + type WorkspaceChartConfig, + type WorkspaceChartMode, + type WorkspaceChartTimeRange, + type WorkspaceChartInterval, +} from "../../../components/charts/WorkspaceChart"; + +import { useTelemetryCatalog } from "../../telemetry/hooks/useTelemetryCatalog"; +import { useTelemetryChartSeries } from "../../telemetry/hooks/useTelemetryChartSeries"; + +import { + useChartWorkspacePersistence, + type PersistedChartWorkspaceItem, +} from "../hooks/useChartWorkspacePersistence"; + +type ChartWorkspaceItem = PersistedChartWorkspaceItem & { + hidden?: boolean; + collapsed?: boolean; + detached?: boolean; + id: string; + title: string; + subtitle: string; + mode: WorkspaceChartMode; + selectedSensorKeys: string[]; + hiddenSensorKeys?: string[]; + timeRange: WorkspaceChartTimeRange; + interval: WorkspaceChartInterval; +}; + +type ChartWindowPageProps = { + theme: "dark" | "light"; +}; + +export function ChartWindowPage({ theme }: ChartWindowPageProps) { + const parts = window.location.pathname.split("/").filter(Boolean); + const chartId = parts[parts.length - 1] ?? ""; + + const { chartableVariables, connected } = useTelemetryCatalog(); + + const [charts, setCharts] = useState([]); + const [configOpen, setConfigOpen] = useState(false); + + const currentWindow = getCurrentWindow(); + + const latestChartRef = useRef(null); + + useChartWorkspacePersistence({ + scope: "GLOBAL", + layoutMode: "fourGrid", + charts, + onLoaded: (workspace) => { + const loadedCharts = workspace.charts as ChartWorkspaceItem[]; + setCharts(loadedCharts); + + latestChartRef.current = + loadedCharts.find((item) => item.id === chartId) ?? null; + }, + }); + + const chart = charts.find((item) => item.id === chartId) ?? null; + + useEffect(() => { + latestChartRef.current = chart; + }, [chart]); + + const emitMainUpdate = async (patch: Partial) => { + await emit("maincharts://update-chart", { + chartId, + patch, + }); + }; + + const emitMainHidden = async () => { + await emit("maincharts://hide-chart", { + chartId, + chart: latestChartRef.current, + }); + }; + + const emitMainAttached = async () => { + await emit("maincharts://attach-chart", { + chartId, + chart: latestChartRef.current, + }); + }; + + useEffect(() => { + const unlistenPromise = currentWindow.onCloseRequested(async (event) => { + event.preventDefault(); + + try { + await emitMainHidden(); + } finally { + await currentWindow.destroy(); + } + }); + + return () => { + void unlistenPromise.then((unlisten) => unlisten()); + }; + }, [chartId]); + + const applyLocalPatch = (patch: Partial) => { + setCharts((current) => + current.map((item) => { + if (item.id !== chartId) return item; + + const next = { + ...item, + ...patch, + }; + + latestChartRef.current = next; + + return next; + }), + ); + }; + + const updateChart = (patch: Partial) => { + applyLocalPatch(patch); + + void emitMainUpdate(patch).catch((error) => { + console.error("Failed to sync chart update to main window", error); + }); + }; + + const toggleVariable = (variableKey: string) => { + const currentChart = latestChartRef.current; + if (!currentChart) return; + + const hiddenSensorKeys = currentChart.hiddenSensorKeys ?? []; + const isHidden = hiddenSensorKeys.includes(variableKey); + + const nextHiddenSensorKeys = isHidden + ? hiddenSensorKeys.filter((key) => key !== variableKey) + : [...hiddenSensorKeys, variableKey]; + + updateChart({ + hiddenSensorKeys: nextHiddenSensorKeys, + }); + }; + + const { seriesByKey, loading } = useTelemetryChartSeries( + chart?.selectedSensorKeys ?? [], + chart?.timeRange ?? "24h", + chart?.interval ?? "5m", + ); + + const selectedVariables = useMemo( + () => + chartableVariables.filter((variable) => + chart?.selectedSensorKeys.includes(variable.key), + ), + [chart?.selectedSensorKeys, chartableVariables], + ); + + const attachAndClose = async () => { + try { + await emitMainAttached(); + } catch (error) { + console.error("Failed to attach chart", error); + } + + await currentWindow.destroy(); + }; + + const closeAndHide = async () => { + try { + await emitMainHidden(); + } catch (error) { + console.error("Failed to hide chart", error); + } + + await currentWindow.destroy(); + }; + + if (!chart) { + return ( +
+ Gráfico não encontrado. +
+ ); + } + + const variablesStillResolving = + chart.selectedSensorKeys.length > 0 && + selectedVariables.length === 0; + + const chartConfig: WorkspaceChartConfig = { + id: chart.id, + title: "", + subtitle: "", + icon: Maximize2, + status: connected ? "online" : "offline", + sourceLabel: connected ? "Telemetry" : "Offline", + mode: chart.mode, + timeRange: chart.timeRange, + interval: chart.interval, + variables: chartableVariables + .filter((variable) => + chart.selectedSensorKeys.includes(variable.key), + ) + .map((variable, index) => ({ + key: variable.key, + label: variable.label, + unit: variable.unit, + color: getVariableColor(index), + data: seriesByKey[variable.key] ?? [], + visible: !(chart.hiddenSensorKeys ?? []).includes(variable.key), + })), + }; + + return ( +
+
+
+ {chart.title} +
+ +
+ + + + + + + + + +
+
+ +
+ updateChart({ mode })} + onVariableToggle={toggleVariable} + onTimeRangeChange={(timeRange) => updateChart({ timeRange })} + onIntervalChange={(interval) => updateChart({ interval })} + /> +
+ + {configOpen && ( + setConfigOpen(false)} + onSave={async (updatedChart) => { + setCharts((current) => + current.map((item) => + item.id === updatedChart.id + ? { ...item, ...updatedChart } + : item, + ), + ); + + latestChartRef.current = { + ...chart, + ...updatedChart, + }; + + await emit("maincharts://replace-chart", { + chart: updatedChart, + }).catch((error) => { + console.error("Failed to replace chart in main window", error); + }); + + setConfigOpen(false); + }} + /> + )} +
+ ); +} + +function getVariableColor(index: number) { + const colors = [ + "#4FD1C5", + "#3B82F6", + "#FACC15", + "#7DD3FC", + "#A5B4FC", + "#FB7185", + ]; + + return colors[index % colors.length]; +} + +export default ChartWindowPage; \ No newline at end of file diff --git a/src/features/maincharts/pages/MainChartsPage.tsx b/src/features/maincharts/pages/MainChartsPage.tsx index 7d38761..5560db5 100644 --- a/src/features/maincharts/pages/MainChartsPage.tsx +++ b/src/features/maincharts/pages/MainChartsPage.tsx @@ -1,4 +1,8 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { ChartConfigModal } from "../components/ChartConfigModal"; +import { openChartWindow } from "../utils/openChartWindow"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { Cog, Copy, @@ -48,8 +52,15 @@ type ChartWorkspaceItem = PersistedChartWorkspaceItem & { 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-[6px]"; @@ -111,7 +122,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { const visibleCharts = useMemo(() => { const openCharts = charts.filter( - (chart) => !chart.hidden && !chart.collapsed, + (chart) => !chart.hidden && !chart.collapsed && !chart.detached, ); if (layoutMode === "single") return openCharts.slice(0, 1); @@ -165,26 +176,33 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { }) => { if (!canAddMoreCharts) return; - const newChart: ChartWorkspaceItem = { - id: `chart-${Date.now()}`, - title, - subtitle: "Gráfico personalizado de telemetria.", - mode, - selectedSensorKeys, - timeRange: "24h", - interval: "5m", - }; + const newChartId = `chart-${Date.now()}`; setCharts((current) => { - const next = [...current, newChart]; + const visibleSlotCount = getVisibleSlotCount(layoutMode); + const visibleOpenCount = current.filter( + (chart) => !chart.hidden && !chart.collapsed && !chart.detached + ).length; - if (next.length >= 3) { - setLayoutMode("fourGrid"); - } else if (next.length === 2) { - setLayoutMode("twoColumns"); + 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 next; + return [...current, newChart]; }); setNewChartOpen(false); @@ -228,30 +246,19 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { current.map((chart) => { if (chart.id !== chartId) return chart; - const alreadySelected = chart.selectedSensorKeys.includes(key); - - if (!alreadySelected && chart.selectedSensorKeys.length >= MAX_VARIABLES_PER_CHART) { - return chart; - } + const hiddenSensorKeys = chart.hiddenSensorKeys ?? []; + const isHidden = hiddenSensorKeys.includes(key); return { ...chart, - selectedSensorKeys: alreadySelected - ? chart.selectedSensorKeys.filter((item) => item !== key) - : [...chart.selectedSensorKeys, key], + hiddenSensorKeys: isHidden + ? hiddenSensorKeys.filter((item) => item !== key) + : [...hiddenSensorKeys, key], }; }), ); }; - const updateChartTitle = (chartId: string, title: string) => { - setCharts((current) => - current.map((chart) => - chart.id === chartId ? { ...chart, title } : chart, - ), - ); - }; - const loadSavedChart = (chartId: string) => { setCharts((current) => { const selected = current.find((chart) => chart.id === chartId); @@ -269,11 +276,18 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { setPlacingChartId(null); }; - const closeChart = (chartId: string) => { + const closeChart = async (chartId: string) => { + await closeDetachedChartWindow(chartId); + setCharts((current) => current.map((chart) => chart.id === chartId - ? { ...chart, hidden: true, collapsed: false } + ? { + ...chart, + detached: false, + hidden: true, + collapsed: false, + } : chart, ), ); @@ -283,7 +297,9 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { if (placingChartId === chartId) setPlacingChartId(null); }; - const deleteChart = (chartId: string) => { + const deleteChart = async (chartId: string) => { + await closeDetachedChartWindow(chartId); + setCharts((current) => current.filter((chart) => chart.id !== chartId)); if (configChartId === chartId) setConfigChartId(null); @@ -295,19 +311,26 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { if (!placingChartId || placingChartId === targetChartId) return; setCharts((current) => { - const source = current.find((chart) => chart.id === placingChartId); + const sourceIndex = current.findIndex((chart) => chart.id === placingChartId); const targetIndex = current.findIndex((chart) => chart.id === targetChartId); - if (!source || targetIndex === -1 || !source.hidden) return current; + if (sourceIndex === -1 || targetIndex === -1) return current; - const withoutSource = current.filter((chart) => chart.id !== placingChartId); - const next = [...withoutSource]; + const next = [...current]; + const source = next[sourceIndex]; + const target = next[targetIndex]; - next.splice(targetIndex, 0, { + next[targetIndex] = { ...source, hidden: false, collapsed: false, - }); + }; + + next[sourceIndex] = { + ...target, + hidden: false, + collapsed: true, + }; return next; }); @@ -317,23 +340,195 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { const openSavedChart = (chartId: string) => { const chart = charts.find((item) => item.id === chartId); - if (!chart || !chart.hidden) return; + if (!chart) return; - const openCount = charts.filter((item) => !item.hidden).length; + const isActuallyOpen = !chart.hidden && !chart.collapsed && !chart.detached; + if (isActuallyOpen) return; - if (openCount === 0) { + const visibleSlotCount = getVisibleSlotCount(layoutMode); + const visibleOpenCount = charts.filter( + (item) => !item.hidden && !item.collapsed && !item.detached, + ).length; + + if (visibleOpenCount === 0) { loadSavedChart(chartId); return; } - if (openCount >= 2) { - setLayoutMode("fourGrid"); + 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(() => { + void openChartWindow(chartId); + }, 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; + }>("maincharts://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; + }>("maincharts://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; + }>( + "maincharts://update-chart", + (event) => { + const { chartId, patch } = event.payload; + + setCharts((current) => + current.map((chart) => + chart.id === chartId + ? { + ...chart, + ...patch, + } + : chart, + ), + ); + }, + ); + + const unlistenReplacePromise = listen<{ chart: ChartWorkspaceItem }>( + "maincharts://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 (
))} @@ -487,7 +685,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { setCharts((current) => { const source = current.find((chart) => chart.id === placingChartId); - if (!source || !source.hidden) return current; + if (!source || (!source.hidden && !source.collapsed)) return current; const withoutSource = current.filter((chart) => chart.id !== placingChartId); return [ @@ -536,9 +734,17 @@ export function MainChartsPage({ theme }: MainChartsPageProps) { chart={configChart} variables={chartableVariables} onClose={() => setConfigChartId(null)} - onTitleChange={(title) => updateChartTitle(configChart.id, title)} - onModeChange={(mode) => setChartMode(configChart.id, mode)} - onVariableToggle={(key) => toggleVariable(configChart.id, key)} + onSave={(updatedChart) => { + setCharts((current) => + current.map((chart) => + chart.id === updatedChart.id + ? { ...chart, ...updatedChart } + : chart, + ), + ); + + setConfigChartId(null); + }} /> )}
@@ -561,6 +767,9 @@ function WorkspaceChartContainer({ setCharts, placingChartId, placeChartHere, + detachChart, + attachChart, + moveDetachedChart }: { theme: "dark" | "light"; chartItem: ChartWorkspaceItem; @@ -570,13 +779,16 @@ function WorkspaceChartContainer({ setMovingChartId: React.Dispatch>; swapCharts: (sourceId: string, targetId: string) => void; duplicateChart: (chartId: string) => void; - closeChart: (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 isMoving = movingChartId === chartItem.id; @@ -626,7 +838,7 @@ function WorkspaceChartContainer({ unit: variable.unit, color: getVariableColor(index), data: seriesByKey[variable.key] ?? [], - visible: true, + visible: !(chartItem.hiddenSensorKeys ?? []).includes(variable.key), })), }; @@ -654,11 +866,25 @@ function WorkspaceChartContainer({ - -
@@ -691,6 +917,9 @@ function WorkspaceChartContainer({ chart={chartConfig} configuredVariableCount={chartItem.selectedSensorKeys.length} loading={loading || variablesStillResolving} + detached={chartItem.detached} + onDetach={() => detachChart(chartItem.id)} + onAttach={() => attachChart(chartItem.id)} onTimeRangeChange={(range) => setCharts((current) => current.map((chart) => @@ -743,6 +972,32 @@ function WorkspaceChartContainer({ 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 + } />
); @@ -769,7 +1024,7 @@ function SavedChartsDropdown({ onSearchChange: (value: string) => void; onClose: () => void; onStartPlacement: (chartId: string) => void; - onDelete: (chartId: string) => void; + onDelete: (chartId: string) => void | Promise; }) { const isDark = theme === "dark"; @@ -828,7 +1083,11 @@ function SavedChartsDropdown({
- {chart.hidden ? ( + {chart.detached && !chart.hidden ? ( + + Destacado + + ) : chart.hidden || chart.collapsed ? ( - - -
-
-
- - onTitleChange(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={() => onModeChange("line")} - /> - } - label="Área" - onClick={() => onModeChange("area")} - /> - } - label="Barras" - onClick={() => onModeChange("bar")} - /> -
-
- -
-

- Selecionadas -

- -

- {chart.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 = chart.selectedSensorKeys.includes(variable.key); - - return ( - = - MAX_VARIABLES_PER_CHART - } - onClick={() => onVariableToggle(variable.key)} - /> - ); - })} - - {filteredVariables.length === 0 && ( - - )} -
-
-
- - -
-
- ); -} - function VariableRow({ theme, variable, diff --git a/src/features/maincharts/utils/openChartWindow.ts b/src/features/maincharts/utils/openChartWindow.ts new file mode 100644 index 0000000..66d1f25 --- /dev/null +++ b/src/features/maincharts/utils/openChartWindow.ts @@ -0,0 +1,49 @@ +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; + +export async function openChartWindow(chartId: string) { + const label = `chart-${chartId}`; + + const existing = await WebviewWindow.getByLabel(label); + + if (existing) { + await existing.show(); + await existing.setFocus(); + return; + } + + const chartWindow = new WebviewWindow(label, { + url: `/chart-window/${chartId}`, + + title: "Chart", + + width: 920, + height: 680, + + minWidth: 720, + minHeight: 480, + + decorations: false, + transparent: false, + + resizable: true, + maximizable: true, + minimizable: true, + closable: true, + + visible: true, + focus: true, + center: true, + + skipTaskbar: false, + }); + + chartWindow.once("tauri://created", () => { + console.log(`Chart window created: ${label}`); + }); + + chartWindow.once("tauri://error", (error) => { + console.error(`Failed to create chart window: ${label}`, error); + }); + + return chartWindow; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index f32a1e5..64507be 100644 --- a/src/index.css +++ b/src/index.css @@ -20,7 +20,7 @@ body { .custom-scrollbar { scrollbar-width: thin; - scrollbar-color: rgba(56, 189, 248, 0.45) rgba(15, 23, 42, 0.35); + scrollbar-color: rgba(71, 85, 105, 0.9) transparent; } .custom-scrollbar::-webkit-scrollbar { @@ -34,19 +34,11 @@ body { } .custom-scrollbar::-webkit-scrollbar-thumb { - background: linear-gradient( - 180deg, - rgba(56, 189, 248, 0.7), - rgba(14, 165, 233, 0.35) - ); + background: rgba(71, 85, 105, 0.75); border: 2px solid rgba(15, 23, 42, 0.9); border-radius: 999px; } .custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: linear-gradient( - 180deg, - rgba(56, 189, 248, 0.95), - rgba(14, 165, 233, 0.55) - ); -} + background: rgba(100, 116, 139, 0.95); +} \ No newline at end of file