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, 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 { useTelemetryCatalog } from "../../telemetry/hooks/useTelemetryCatalog"; import type { ChartVariable } from "../../telemetry/types/telemetryCatalog"; import { useTelemetryChartSeries } from "../../telemetry/hooks/useTelemetryChartSeries"; import { useChartWorkspacePersistence, type PersistedChartWorkspaceItem, type ChartLayoutMode, } from "../../chartworkspace/hooks/useChartWorkspacePersistence"; type MainChartsPageProps = { theme: "dark" | "light"; }; 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-[6px]"; const MAX_CHARTS = 10; const MAX_VARIABLES_PER_CHART = 6; const INITIAL_CHARTS: ChartWorkspaceItem[] = []; export function MainChartsPage({ theme }: MainChartsPageProps) { const isDark = theme === "dark"; const { chartableVariables, connected } = useTelemetryCatalog(); 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: "GLOBAL", 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 === chartId ? { ...chart, mode } : chart, ), ); }; 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 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 === chartId ? { ...chart, 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", ); }, 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 (
} 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")} />
{!workspacePersistence.loaded ? "A carregar..." : workspacePersistence.saving ? "A guardar..." : workspacePersistence.error ? workspacePersistence.error : "Workspace guardado"}
{savedOpen && ( { setSavedOpen(false); setPlacingChartId(null); }} onStartPlacement={openSavedChart} onDelete={deleteChart} /> )}
{visibleCharts.length === 0 ? ( { if (canAddMoreCharts) setNewChartOpen(true); }} onOpenSaved={() => setSavedOpen(true)} /> ) : (
{visibleCharts.map((chartItem) => ( ))} {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 WorkspaceChartContainer({ theme, chartItem, chartableVariables, connected, movingChartId, setMovingChartId, swapCharts, duplicateChart, closeChart, setConfigChartId, setChartMode, toggleVariable, setCharts, placingChartId, placeChartHere, detachChart, attachChart, moveDetachedChart }: { theme: "dark" | "light"; 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 isMoving = movingChartId === chartItem.id; const canReceiveMove = (movingChartId !== null && movingChartId !== chartItem.id) || (placingChartId !== null && placingChartId !== chartItem.id); const { seriesByKey, loading } = useTelemetryChartSeries( chartItem.selectedSensorKeys, chartItem.timeRange, chartItem.interval, ); const selectedVariables = useMemo( () => chartableVariables.filter((variable) => chartItem.selectedSensorKeys.includes(variable.key), ), [ chartItem.selectedSensorKeys.join("|"), chartableVariables.map((variable) => `${variable.key}:${variable.label}:${variable.unit}`, ).join("|"), ], ); const variablesStillResolving = chartItem.selectedSensorKeys.length > 0 && selectedVariables.length === 0; 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 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, }: { 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 MainChartsPage;