Files
litoral-central-frontend/src/features/maincharts/pages/MainChartsPage.tsx
T

1649 lines
62 KiB
TypeScript

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<ChartLayoutMode>("twoColumns");
const [charts, setCharts] = useState<ChartWorkspaceItem[]>(INITIAL_CHARTS);
const [configChartId, setConfigChartId] = useState<string | null>(null);
const [savedSearch, setSavedSearch] = useState("");
const [savedOpen, setSavedOpen] = useState(false);
const [movingChartId, setMovingChartId] = useState<string | null>(null);
const [newChartOpen, setNewChartOpen] = useState(false);
const [placingChartId, setPlacingChartId] = useState<string | null>(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<ChartWorkspaceItem>;
}>(
"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 (
<div className="space-y-4 pb-6">
<div
className={
isDark
? `${RADIUS} relative flex flex-wrap items-center gap-3 border border-[#263247] bg-[#0E1726] p-2`
: `${RADIUS} relative flex flex-wrap items-center gap-3 border border-[#D7DEE8] bg-white p-2`
}
>
<div className="flex items-center gap-2">
<LayoutButton
theme={theme}
active={layoutMode === "single"}
icon={<PanelTop className="h-4 w-4" />}
title="1 gráfico"
onClick={() => changeLayoutMode("single")}
/>
<LayoutButton
theme={theme}
active={layoutMode === "twoColumns"}
icon={<Columns2 className="h-4 w-4" />}
title="2 lado a lado"
onClick={() => changeLayoutMode("twoColumns")}
/>
<LayoutButton
theme={theme}
active={layoutMode === "twoRows"}
icon={<Rows2 className="h-4 w-4" />}
title="2 vertical"
onClick={() => changeLayoutMode("twoRows")}
/>
<LayoutButton
theme={theme}
active={layoutMode === "fourGrid"}
icon={<Grid2X2 className="h-4 w-4" />}
title="4 gráficos"
onClick={() => changeLayoutMode("fourGrid")}
/>
</div>
<div className="ml-auto flex items-center gap-4">
<div
className={
isDark
? "text-xs font-bold text-[#7F8CA3]"
: "text-xs font-bold text-slate-500"
}
>
{!workspacePersistence.loaded
? "A carregar..."
: workspacePersistence.saving
? "A guardar..."
: workspacePersistence.error
? workspacePersistence.error
: "Workspace guardado"}
</div>
<div className="relative">
<button
type="button"
onClick={() => setSavedOpen((value) => !value)}
className={
isDark
? `flex h-10 items-center gap-3 ${RADIUS} border border-[#263247] bg-[#111A2B] px-4 text-sm font-bold text-white transition hover:border-[#36506D]`
: `flex h-10 items-center gap-3 ${RADIUS} border border-[#D7DEE8] bg-white px-4 text-sm font-bold text-[#0F172A] transition hover:bg-[#F8FAFC]`
}
>
<Search className="h-4 w-4" />
Gráficos Guardados
<ChevronDown
className={
savedOpen
? "h-4 w-4 rotate-180 text-[#7F8CA3] transition"
: "h-4 w-4 text-[#7F8CA3] transition"
}
/>
</button>
{savedOpen && (
<SavedChartsDropdown
theme={theme}
charts={savedCharts}
search={savedSearch}
onSearchChange={setSavedSearch}
onClose={() => {
setSavedOpen(false);
setPlacingChartId(null);
}}
onStartPlacement={openSavedChart}
onDelete={deleteChart}
/>
)}
</div>
<button
type="button"
disabled={!canAddMoreCharts}
onClick={() => setNewChartOpen(true)}
className={
canAddMoreCharts
? `flex h-10 items-center gap-3 ${RADIUS} bg-[#18B8A6] px-5 text-sm font-black text-white transition hover:bg-[#21C7B5]`
: `flex h-10 cursor-not-allowed items-center gap-3 ${RADIUS} bg-[#18B8A6]/40 px-5 text-sm font-black text-white`
}
>
<Plus className="h-5 w-5" />
Novo Gráfico
</button>
</div>
</div>
{visibleCharts.length === 0 ? (
<EmptyWorkspace
theme={theme}
canAddMoreCharts={canAddMoreCharts}
onAddChart={() => {
if (canAddMoreCharts) setNewChartOpen(true);
}}
onOpenSaved={() => setSavedOpen(true)}
/>
) : (
<section className={layoutGridClass(layoutMode)}>
{visibleCharts.map((chartItem) => (
<WorkspaceChartContainer
key={chartItem.id}
theme={theme}
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}
/>
))}
{placingChartId && visibleCharts.length < getVisibleSlotCount(layoutMode) && (
<button
type="button"
onClick={() => {
if (!placingChartId) return;
setCharts((current) => {
const source = current.find((chart) => chart.id === placingChartId);
if (!source || (!source.hidden && !source.collapsed)) return current;
const withoutSource = current.filter((chart) => chart.id !== placingChartId);
return [
...visibleCharts,
{
...source,
hidden: false,
collapsed: false,
},
...withoutSource.filter(
(chart) => !visibleCharts.some((visible) => visible.id === chart.id),
)];
});
if (visibleCharts.length + 1 >= 3) {
setLayoutMode("fourGrid");
}
setPlacingChartId(null);
}}
className={
isDark
? `${RADIUS} flex min-h-[360px] items-center justify-center border-2 border-dashed border-[#4FD1C5] bg-[#07101B]/60 text-sm font-black text-[#4FD1C5]`
: `${RADIUS} flex min-h-[360px] items-center justify-center border-2 border-dashed border-[#0F766E] bg-[#ECFDF5] text-sm font-black text-[#0F766E]`
}
>
Colocar em novo espaço
</button>
)}
</section>
)}
{newChartOpen && (
<NewChartModal
theme={theme}
variables={chartableVariables}
chartNumber={charts.length + 1}
onClose={() => setNewChartOpen(false)}
onCreate={addChart}
/>
)}
{configChart && (
<ChartConfigModal
theme={theme}
chart={configChart}
variables={chartableVariables}
onClose={() => setConfigChartId(null)}
onSave={(updatedChart) => {
setCharts((current) =>
current.map((chart) =>
chart.id === updatedChart.id
? { ...chart, ...updatedChart }
: chart,
),
);
setConfigChartId(null);
}}
/>
)}
</div>
);
}
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<React.SetStateAction<string | null>>;
swapCharts: (sourceId: string, targetId: string) => void;
duplicateChart: (chartId: string) => void;
closeChart: (chartId: string) => void | Promise<void>;
setConfigChartId: React.Dispatch<React.SetStateAction<string | null>>;
setChartMode: (chartId: string, mode: WorkspaceChartMode) => void;
toggleVariable: (chartId: string, key: string) => void;
setCharts: React.Dispatch<React.SetStateAction<ChartWorkspaceItem[]>>;
placingChartId: string | null;
placeChartHere: (targetChartId: string) => void;
detachChart: (chartId: string) => void;
attachChart: (chartId: string) => void | Promise<void>;
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 (
<div
onClick={() => {
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]"
}
>
<div className="absolute right-4 top-4 z-20 flex items-center gap-1.5">
<button type="button" title="Configurar" className={floatingIconClass(theme)} onClick={() => setConfigChartId(chartItem.id)}>
<Cog className="h-4 w-4" />
</button>
<button type="button" title="Duplicar" className={floatingIconClass(theme)} onClick={() => duplicateChart(chartItem.id)}>
<Copy className="h-4 w-4" />
</button>
<button
type="button"
title={chartItem.detached ? "Repor janela" : "Destacar janela"}
className={floatingIconClass(theme)}
onClick={() =>
chartItem.detached
? attachChart(chartItem.id)
: detachChart(chartItem.id)
}
>
<Maximize2 className="h-4 w-4" />
</button>
<button
type="button"
title="Fechar"
className={floatingIconClass(theme)}
onClick={() => void closeChart(chartItem.id)}
>
<X className="h-4 w-4" />
</button>
</div>
{canReceiveMove && (
<div className="absolute inset-0 z-30 flex items-center justify-center rounded-[7px] bg-[#07101B]/55 backdrop-blur-[2px]">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
if (placingChartId && placingChartId !== chartItem.id) {
placeChartHere(chartItem.id);
return;
}
if (movingChartId && movingChartId !== chartItem.id) {
swapCharts(movingChartId, chartItem.id);
}
}}
className="rounded-[6px] bg-[#4FD1C5] px-5 py-2.5 text-sm font-black text-[#07101B] shadow-xl transition hover:bg-[#63E6D8]"
>
Colocar aqui
</button>
</div>
)}
<WorkspaceChart
theme={theme}
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) =>
chart.id === chartItem.id
? { ...chart, timeRange: range }
: chart,
),
)
}
onIntervalChange={(interval) =>
setCharts((current) =>
current.map((chart) =>
chart.id === chartItem.id
? { ...chart, interval }
: chart,
),
)
}
dragHandle={
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setMovingChartId((current) =>
current === chartItem.id
? null
: chartItem.id,
);
}}
title={
isMoving
? "Cancelar movimento"
: "Escolher este gráfico para mover"
}
className={
isMoving
? `${RADIUS} border border-[#4FD1C5] bg-[#4FD1C5] p-2 text-[#07101B]`
: theme === "dark"
? `${RADIUS} border border-[#263247] bg-[#111A2B] p-2 text-[#7F8CA3] transition hover:text-white`
: `${RADIUS} border border-[#D7DEE8] bg-white p-2 text-slate-500 transition hover:text-[#0F172A]`
}
>
<GripVertical className="h-4 w-4" />
</button>
}
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
}
/>
</div>
);
}
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<void>;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} absolute right-0 top-[calc(100%+8px)] z-40 w-[430px] border border-[#263247] bg-[#0E1726] p-3 shadow-2xl`
: `${RADIUS} absolute right-0 top-[calc(100%+8px)] z-40 w-[430px] border border-[#D7DEE8] bg-white p-3 shadow-2xl`
}
>
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="text-sm font-black">Gráficos Guardados</h2>
<p className={isDark ? "mt-1 text-xs text-[#7F8CA3]" : "mt-1 text-xs text-slate-500"}>
{charts.length}/10 gráficos no workspace.
</p>
</div>
<button type="button" onClick={onClose}>
<X className="h-4 w-4" />
</button>
</div>
<div
className={
isDark
? `${RADIUS} mb-3 flex h-10 items-center gap-2 border border-[#263247] bg-[#07101B] px-3 text-sm text-[#7F8CA3]`
: `${RADIUS} mb-3 flex h-10 items-center gap-2 border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm text-slate-500`
}
>
<Search className="h-4 w-4" />
<input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Pesquisar gráficos..."
className="w-full bg-transparent outline-none placeholder:text-inherit"
/>
</div>
<div className="max-h-[280px] space-y-2 overflow-y-auto pr-1">
{charts.map((chart) => (
<div
key={chart.id}
className={
isDark
? `${RADIUS} flex w-full items-center justify-between gap-3 border border-[#263247] bg-[#111A2B] px-3 py-3 transition hover:border-[#36506D]`
: `${RADIUS} flex w-full items-center justify-between gap-3 border border-[#D7DEE8] bg-[#F8FAFC] px-3 py-3 transition hover:bg-white`
}
>
<div className="min-w-0">
<p className="truncate text-sm font-black">{chart.title}</p>
<p className={isDark ? "mt-1 truncate text-xs text-[#7F8CA3]" : "mt-1 truncate text-xs text-slate-500"}>
{chart.selectedSensorKeys.length} variáveis · {chart.timeRange} · {chart.interval}
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
{chart.detached && !chart.hidden ? (
<span className="text-xs font-black text-[#FACC15]">
Destacado
</span>
) : chart.hidden || chart.collapsed ? (
<button
type="button"
onClick={() => onStartPlacement(chart.id)}
title="Abrir gráfico"
className="text-[#7F8CA3] transition hover:text-[#4FD1C5]"
>
<Play className="h-4 w-4" />
</button>
) : (
<span className="text-xs font-black text-[#4FD1C5]">
Aberto
</span>
)}
<button
type="button"
onClick={() => void onDelete(chart.id)}
title="Eliminar gráfico"
className="text-[#7F8CA3] transition hover:text-red-400"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
{charts.length === 0 && (
<div className={isDark ? `${RADIUS} border border-dashed border-[#263247] px-4 py-6 text-center text-sm font-bold text-[#7F8CA3]` : `${RADIUS} border border-dashed border-[#D7DEE8] px-4 py-6 text-center text-sm font-bold text-slate-500`}>
Nenhum gráfico guardado.
</div>
)}
</div>
</div>
);
}
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<WorkspaceChartMode>("line");
const [search, setSearch] = useState("");
const [selectedSensorKeys, setSelectedSensorKeys] = useState<string[]>([]);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-6">
<div
className={
isDark
? `${RADIUS} w-full max-w-[760px] border border-[#263247] bg-[#0E1726] shadow-2xl`
: `${RADIUS} w-full max-w-[760px] border border-[#D7DEE8] bg-white shadow-2xl`
}
>
<header
className={
isDark
? "flex items-center justify-between border-b border-[#263247] px-6 py-5"
: "flex items-center justify-between border-b border-[#D7DEE8] px-6 py-5"
}
>
<div>
<p
className={
isDark
? "text-xs font-black uppercase tracking-[0.2em] text-[#4FD1C5]"
: "text-xs font-black uppercase tracking-[0.2em] text-[#0F766E]"
}
>
Novo gráfico
</p>
<h2 className="mt-1 text-xl font-black">Criar gráfico</h2>
</div>
<button
type="button"
onClick={onClose}
className={
isDark
? `${RADIUS} p-2 text-[#A8B3C7] transition hover:bg-[#111A2B] hover:text-white`
: `${RADIUS} p-2 text-slate-500 transition hover:bg-[#F8FAFC] hover:text-[#0F172A]`
}
>
<X className="h-5 w-5" />
</button>
</header>
<main className="grid gap-6 p-6 md:grid-cols-[260px_minmax(0,1fr)]">
<section className="space-y-4">
<div>
<label className="mb-2 block text-sm font-black">Nome</label>
<input
value={title}
onChange={(event) => 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`
}
/>
</div>
<div>
<label className="mb-2 block text-sm font-black">
Tipo de gráfico
</label>
<div className="grid grid-cols-3 gap-2">
<ModeButton
theme={theme}
active={mode === "line"}
icon={<LineChart className="h-4 w-4" />}
label="Linha"
onClick={() => setMode("line")}
/>
<ModeButton
theme={theme}
active={mode === "area"}
icon={<AreaChart className="h-4 w-4" />}
label="Área"
onClick={() => setMode("area")}
/>
<ModeButton
theme={theme}
active={mode === "bar"}
icon={<BarChart3 className="h-4 w-4" />}
label="Barras"
onClick={() => setMode("bar")}
/>
</div>
</div>
<div
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#111A2B] p-3`
: `${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] p-3`
}
>
<p className="text-xs font-black uppercase tracking-[0.18em] text-[#7F8CA3]">
Selecionadas
</p>
<p className="mt-2 text-sm font-black">
{selectedSensorKeys.length}/{MAX_VARIABLES_PER_CHART}
</p>
</div>
</section>
<section>
<label className="mb-2 block text-sm font-black">
Variáveis de telemetria
</label>
<div
className={
isDark
? `${RADIUS} mb-3 flex h-10 items-center gap-2 border border-[#263247] bg-[#07101B] px-3 text-sm text-[#7F8CA3]`
: `${RADIUS} mb-3 flex h-10 items-center gap-2 border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm text-slate-500`
}
>
<Search className="h-4 w-4" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Pesquisar variável..."
className="w-full bg-transparent outline-none placeholder:text-inherit"
/>
</div>
<div className="max-h-[320px] space-y-2 overflow-y-auto pr-1">
{filteredVariables.map((variable, index) => {
const active = selectedSensorKeys.includes(variable.key);
return (
<VariableRow
key={variable.key}
theme={theme}
variable={variable}
color={getVariableColor(index)}
active={active}
disabled={
!active &&
selectedSensorKeys.length >= MAX_VARIABLES_PER_CHART
}
onClick={() => toggleVariable(variable.key)}
/>
);
})}
{filteredVariables.length === 0 && (
<EmptyVariableList theme={theme} />
)}
</div>
</section>
</main>
<footer
className={
isDark
? "flex justify-end gap-3 border-t border-[#263247] px-6 py-5"
: "flex justify-end gap-3 border-t border-[#D7DEE8] px-6 py-5"
}
>
<button
type="button"
onClick={onClose}
className={
isDark
? `${RADIUS} border border-[#263247] bg-[#111A2B] px-5 py-2.5 text-sm font-bold text-white`
: `${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] px-5 py-2.5 text-sm font-bold text-[#0F172A]`
}
>
Cancelar
</button>
<button
type="button"
disabled={!canCreate}
onClick={() =>
onCreate({
title: title.trim(),
mode,
selectedSensorKeys,
})
}
className={
canCreate
? `flex items-center gap-2 ${RADIUS} bg-[#18B8A6] px-5 py-2.5 text-sm font-black text-white transition hover:bg-[#21C7B5]`
: `flex cursor-not-allowed items-center gap-2 ${RADIUS} bg-[#18B8A6]/40 px-5 py-2.5 text-sm font-black text-white`
}
>
<Plus className="h-4 w-4" />
Criar
</button>
</footer>
</div>
</div>
);
}
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 (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={
active
? isDark
? `${RADIUS} flex h-12 w-full items-center justify-between border border-[#36506D] bg-[#132033] px-3 text-left text-sm font-bold text-white`
: `${RADIUS} flex h-12 w-full items-center justify-between border border-[#0F766E]/30 bg-[#ECFDF5] px-3 text-left text-sm font-bold text-[#0F172A]`
: disabled
? isDark
? `${RADIUS} flex h-12 w-full cursor-not-allowed items-center justify-between border border-[#263247] bg-[#111A2B]/50 px-3 text-left text-sm font-semibold text-[#7F8CA3]/50`
: `${RADIUS} flex h-12 w-full cursor-not-allowed items-center justify-between border border-[#D7DEE8] bg-[#F8FAFC]/60 px-3 text-left text-sm font-semibold text-slate-400`
: isDark
? `${RADIUS} flex h-12 w-full items-center justify-between border border-[#263247] bg-[#111A2B] px-3 text-left text-sm font-semibold text-white transition hover:border-[#36506D]`
: `${RADIUS} flex h-12 w-full items-center justify-between border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-left text-sm font-semibold text-[#0F172A] transition hover:bg-white`
}
>
<span className="flex min-w-0 items-center gap-2">
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="min-w-0">
<span className="block truncate">{variable.label}</span>
<span
className={
isDark
? "mt-0.5 block truncate text-xs font-bold text-[#7F8CA3]"
: "mt-0.5 block truncate text-xs font-bold text-slate-500"
}
>
{variable.group} · {variable.category}
{variable.unit ? ` · ${variable.unit}` : ""}
</span>
</span>
</span>
<span
className={
active
? "text-xs font-black text-[#4FD1C5]"
: "text-xs font-bold text-[#7F8CA3]"
}
>
{active ? "ON" : "OFF"}
</span>
</button>
);
}
function EmptyVariableList({ theme }: { theme: "dark" | "light" }) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-dashed border-[#263247] px-4 py-6 text-center text-sm font-bold text-[#7F8CA3]`
: `${RADIUS} border border-dashed border-[#D7DEE8] px-4 py-6 text-center text-sm font-bold text-slate-500`
}
>
Nenhuma variável encontrada.
</div>
);
}
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 (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className={
active
? isDark
? `${RADIUS} grid h-10 w-10 place-items-center bg-[#4FD1C5] text-[#07101B]`
: `${RADIUS} grid h-10 w-10 place-items-center bg-[#0F766E] text-white`
: isDark
? `${RADIUS} grid h-10 w-10 place-items-center border border-[#263247] bg-[#111A2B] text-[#A8B3C7] transition hover:border-[#36506D] hover:text-white`
: `${RADIUS} grid h-10 w-10 place-items-center border border-[#D7DEE8] bg-[#F8FAFC] text-slate-600 transition hover:bg-white hover:text-[#0F172A]`
}
>
{icon}
</button>
);
}
function ModeButton({
theme,
active,
icon,
label,
onClick,
}: {
theme: "dark" | "light";
active: boolean;
icon?: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={
active
? theme === "dark"
? `${RADIUS} flex items-center justify-center gap-2 bg-[#4FD1C5] px-3 py-2 text-xs font-black text-[#07101B]`
: `${RADIUS} flex items-center justify-center gap-2 bg-[#0F766E] px-3 py-2 text-xs font-black text-white`
: theme === "dark"
? `${RADIUS} flex items-center justify-center gap-2 border border-[#263247] bg-[#111A2B] px-3 py-2 text-xs font-bold text-[#A8B3C7]`
: `${RADIUS} flex items-center justify-center gap-2 border border-[#D7DEE8] bg-[#F8FAFC] px-3 py-2 text-xs font-bold text-slate-600`
}
>
{icon}
{label}
</button>
);
}
function EmptyWorkspace({
theme,
canAddMoreCharts,
onAddChart,
onOpenSaved,
}: {
theme: "dark" | "light";
canAddMoreCharts: boolean;
onAddChart: () => void;
onOpenSaved: () => void;
}) {
const isDark = theme === "dark";
return (
<section
className={
isDark
? `${RADIUS} flex min-h-[520px] items-center justify-center border border-dashed border-[#33445F] bg-[#0E1726]/70`
: `${RADIUS} flex min-h-[520px] items-center justify-center border border-dashed border-[#CBD5E1] bg-white`
}
>
<div className="max-w-[420px] text-center">
<div
className={
isDark
? "mx-auto grid h-16 w-16 place-items-center rounded-2xl border border-[#263247] bg-[#111A2B] text-[#4FD1C5]"
: "mx-auto grid h-16 w-16 place-items-center rounded-2xl border border-[#D7DEE8] bg-[#F8FAFC] text-[#0F766E]"
}
>
<BarChart3 className="h-8 w-8" />
</div>
<h2
className={
isDark
? "mt-5 text-xl font-black text-white"
: "mt-5 text-xl font-black text-[#0F172A]"
}
>
Nenhum gráfico aberto
</h2>
<p
className={
isDark
? "mt-3 text-sm leading-6 text-[#A8B3C7]"
: "mt-3 text-sm leading-6 text-slate-600"
}
>
Abra um gráfico guardado ou crie um novo gráfico para continuar.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
<button
type="button"
disabled={!canAddMoreCharts}
onClick={onAddChart}
className={
canAddMoreCharts
? `inline-flex h-11 items-center gap-3 ${RADIUS} bg-[#18B8A6] px-5 text-sm font-black text-white transition hover:bg-[#21C7B5]`
: `inline-flex h-11 cursor-not-allowed items-center gap-3 ${RADIUS} bg-[#18B8A6]/40 px-5 text-sm font-black text-white`
}
>
<Plus className="h-5 w-5" />
Novo Gráfico
</button>
<button
type="button"
onClick={onOpenSaved}
className={
isDark
? `inline-flex h-11 items-center gap-3 ${RADIUS} border border-[#263247] bg-[#111A2B] px-5 text-sm font-black text-white`
: `inline-flex h-11 items-center gap-3 ${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] px-5 text-sm font-black text-[#0F172A]`
}
>
<Search className="h-5 w-5" />
Abrir Guardado
</button>
</div>
</div>
</section>
);
}
function getVariableColor(index: number) {
const colors = [
"#4FD1C5",
"#3B82F6",
"#FACC15",
"#7DD3FC",
"#A5B4FC",
"#FB7185",
];
return colors[index % colors.length];
}
export default MainChartsPage;