1649 lines
62 KiB
TypeScript
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; |