Fix detached chart window close and attach behavior
This commit is contained in:
@@ -2,9 +2,23 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "chart-*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
|
||||
"core:webview:allow-create-webview-window",
|
||||
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-destroy",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
}
|
||||
+14
-19
@@ -7,6 +7,7 @@ import { MeteoPage } from "../features/meteo/pages/MeteoPage";
|
||||
import { ClimateChartsPage } from "../features/climate/pages/ClimateChartsPage";
|
||||
import { ConsolePage } from "../features/console/pages/ConsolePage";
|
||||
import { MainChartsPage } from "../features/maincharts/pages/MainChartsPage";
|
||||
import { ChartWindowPage } from "../features/maincharts/pages/ChartWindowPage";
|
||||
import { SettingsPage } from "../features/settings/pages/SettingsPage";
|
||||
|
||||
export type AppPage =
|
||||
@@ -15,15 +16,11 @@ export type AppPage =
|
||||
| "console"
|
||||
| "maincharts"
|
||||
| "settings"
|
||||
|
||||
// CLIMATE
|
||||
| "climate"
|
||||
| "climateCharts"
|
||||
| "climateLighting"
|
||||
| "climateVentilation"
|
||||
| "climateSynoptic"
|
||||
|
||||
// IRRIGATION
|
||||
| "irrigation"
|
||||
| "irrigationCharts"
|
||||
| "irrigationFilters"
|
||||
@@ -32,35 +29,33 @@ export type AppPage =
|
||||
| "irrigationSynoptic";
|
||||
|
||||
function App() {
|
||||
const [activePage, setActivePage] =
|
||||
useState<AppPage>("dashboard");
|
||||
const [activePage, setActivePage] = useState<AppPage>("dashboard");
|
||||
|
||||
const isChartWindow = window.location.pathname.startsWith("/chart-window/");
|
||||
|
||||
if (isChartWindow) {
|
||||
return <ChartWindowPage theme="dark" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
activePage={activePage}
|
||||
onNavigate={setActivePage}
|
||||
>
|
||||
<AppShell activePage={activePage} onNavigate={setActivePage}>
|
||||
{({ theme }) => {
|
||||
if (activePage === "meteo") {
|
||||
return <MeteoPage theme={theme} />;
|
||||
}
|
||||
if (activePage === "meteo") return <MeteoPage theme={theme} />;
|
||||
|
||||
if (activePage === "climateCharts") {
|
||||
return <ClimateChartsPage theme={theme} />;
|
||||
}
|
||||
|
||||
|
||||
if (activePage === "console") {
|
||||
return <ConsolePage theme={theme} />;
|
||||
}
|
||||
|
||||
if (activePage === "console") return <ConsolePage theme={theme} />;
|
||||
|
||||
if (activePage === "maincharts") {
|
||||
return <MainChartsPage theme={theme} />;
|
||||
}
|
||||
|
||||
if (activePage === "settings") {
|
||||
return <SettingsPage theme={theme} />
|
||||
return <SettingsPage theme={theme} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardPage
|
||||
theme={theme}
|
||||
|
||||
@@ -276,9 +276,13 @@ export function WorkspaceChart({
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} overflow-hidden border border-[#223049] bg-[#0F1726] text-slate-100 shadow-[0_18px_50px_rgba(0,0,0,0.24)]`
|
||||
: `${RADIUS} overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_14px_34px_rgba(15,23,42,0.08)]`
|
||||
detached
|
||||
? isDark
|
||||
? `${RADIUS} flex h-full flex-col overflow-hidden border-0 bg-[#0F1726] text-slate-100`
|
||||
: `${RADIUS} flex h-full flex-col overflow-hidden border-0 bg-white text-slate-950`
|
||||
: isDark
|
||||
? `${RADIUS} overflow-hidden border border-[#223049] bg-[#0F1726] text-slate-100 shadow-[0_18px_50px_rgba(0,0,0,0.24)]`
|
||||
: `${RADIUS} overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_14px_34px_rgba(15,23,42,0.08)]`
|
||||
}
|
||||
>
|
||||
<header
|
||||
@@ -288,25 +292,31 @@ export function WorkspaceChart({
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3">
|
||||
{dragHandle && <div className="mt-1 shrink-0">{dragHandle}</div>}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h2 className="truncate text-[19px] font-black tracking-[-0.035em]">
|
||||
{chart.title}
|
||||
</h2>
|
||||
{(chart.title || chart.subtitle) && (
|
||||
<div className="min-w-0 flex-1">
|
||||
{chart.title ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h2 className="truncate text-[19px] font-black tracking-[-0.035em]">
|
||||
{chart.title}
|
||||
</h2>
|
||||
|
||||
<span
|
||||
className={
|
||||
chart.status === "online"
|
||||
? "h-2 w-2 rounded-full bg-[#4FD1C5]"
|
||||
: "h-2 w-2 rounded-full bg-slate-500"
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
chart.status === "online"
|
||||
? "h-2 w-2 rounded-full bg-[#4FD1C5]"
|
||||
: "h-2 w-2 rounded-full bg-slate-500"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{chart.subtitle ? (
|
||||
<p className="mt-1 truncate text-xs text-[#8290A6]">
|
||||
{chart.subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 truncate text-xs text-[#8290A6]">
|
||||
{chart.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
@@ -345,12 +355,22 @@ export function WorkspaceChart({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="px-4 pb-4 sm:px-5 sm:pb-5">
|
||||
<main
|
||||
className={
|
||||
detached
|
||||
? "flex min-h-0 flex-1 flex-col px-4 pb-4 sm:px-5 sm:pb-5"
|
||||
: "px-4 pb-4 sm:px-5 sm:pb-5"
|
||||
}
|
||||
>
|
||||
<section
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} bg-[#09111F] p-3 sm:p-4`
|
||||
: `${RADIUS} bg-slate-50 p-3 sm:p-4`
|
||||
detached
|
||||
? isDark
|
||||
? `${RADIUS} flex min-h-0 flex-1 flex-col bg-[#09111F] p-3 sm:p-4`
|
||||
: `${RADIUS} flex min-h-0 flex-1 flex-col bg-slate-50 p-3 sm:p-4`
|
||||
: isDark
|
||||
? `${RADIUS} bg-[#09111F] p-3 sm:p-4`
|
||||
: `${RADIUS} bg-slate-50 p-3 sm:p-4`
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
@@ -590,7 +610,13 @@ export function WorkspaceChart({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative h-[340px] min-h-[340px]">
|
||||
<div
|
||||
className={
|
||||
detached
|
||||
? "relative min-h-0 flex-1"
|
||||
: "relative h-[340px] min-h-[340px]"
|
||||
}
|
||||
>
|
||||
{visibleVariables.length === 0 && !shouldShowLoading ? (
|
||||
<EmptyChartMessage message="Escolha pelo menos uma variável." />
|
||||
) : data.length === 0 && !shouldShowLoading ? (
|
||||
@@ -693,7 +719,7 @@ export function WorkspaceChart({
|
||||
</div>
|
||||
|
||||
{showIndicators && primaryVariable && (
|
||||
<div className="mt-3 overflow-x-auto border-t border-white/[0.06] pt-3">
|
||||
<div className="mt-3 shrink-0 overflow-x-auto border-t border-white/[0.06] pt-3">
|
||||
<div className="flex min-w-max items-center gap-5 text-xs">
|
||||
<InlineMetric
|
||||
label="Atual"
|
||||
@@ -792,7 +818,11 @@ function ChartScaffold({
|
||||
))}
|
||||
|
||||
<Tooltip
|
||||
cursor={{ stroke: "#64748b", strokeWidth: 1, strokeOpacity: 0.45 }}
|
||||
cursor={{
|
||||
fill: "rgba(255,255,255,0.04)",
|
||||
stroke: "rgba(79,209,197,0.12)",
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
labelFormatter={(_, payload) => {
|
||||
const timestamp = payload?.[0]?.payload?.timestamp;
|
||||
return timestamp ? formatTooltipDate(timestamp) : "";
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
BarChart3,
|
||||
LineChart,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import type {
|
||||
WorkspaceChartMode,
|
||||
WorkspaceChartTimeRange,
|
||||
WorkspaceChartInterval,
|
||||
} from "../../../components/charts/WorkspaceChart";
|
||||
import type { ChartVariable } from "../../telemetry/types/telemetryCatalog";
|
||||
|
||||
export type ChartConfigModalChart = {
|
||||
id: string;
|
||||
title: string;
|
||||
mode: WorkspaceChartMode;
|
||||
selectedSensorKeys: string[];
|
||||
hiddenSensorKeys?: string[];
|
||||
timeRange: WorkspaceChartTimeRange;
|
||||
interval: WorkspaceChartInterval;
|
||||
};
|
||||
|
||||
type ChartConfigModalProps = {
|
||||
theme: "dark" | "light";
|
||||
chart: ChartConfigModalChart;
|
||||
variables: ChartVariable[];
|
||||
maxVariables?: number;
|
||||
onClose?: () => void;
|
||||
onSave: (chart: ChartConfigModalChart) => void;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[6px]";
|
||||
|
||||
export function ChartConfigModal({
|
||||
theme,
|
||||
chart,
|
||||
variables,
|
||||
maxVariables = 6,
|
||||
onClose,
|
||||
onSave,
|
||||
}: ChartConfigModalProps) {
|
||||
const isDark = theme === "dark";
|
||||
const [search, setSearch] = useState("");
|
||||
const [draft, setDraft] = useState<ChartConfigModalChart>(chart);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(chart);
|
||||
}, [chart.id]);
|
||||
|
||||
const filteredVariables = variables.filter((variable) =>
|
||||
`${variable.label} ${variable.category} ${variable.group} ${variable.unit}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const toggleVariable = (key: string) => {
|
||||
setDraft((current) => {
|
||||
const alreadySelected = current.selectedSensorKeys.includes(key);
|
||||
|
||||
if (!alreadySelected && current.selectedSensorKeys.length >= maxVariables) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const selectedSensorKeys = alreadySelected
|
||||
? current.selectedSensorKeys.filter((item) => item !== key)
|
||||
: [...current.selectedSensorKeys, key];
|
||||
|
||||
return {
|
||||
...current,
|
||||
selectedSensorKeys,
|
||||
hiddenSensorKeys: (current.hiddenSensorKeys ?? []).filter(
|
||||
(item) => item !== key,
|
||||
),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const discardAndClose = () => {
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const saveAndClose = () => {
|
||||
onSave(draft);
|
||||
};
|
||||
|
||||
return (
|
||||
<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]"
|
||||
}
|
||||
>
|
||||
Configuração do gráfico
|
||||
</p>
|
||||
|
||||
<h2 className="mt-1 text-xl font-black">{draft.title}</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={discardAndClose}
|
||||
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={draft.title}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} h-11 w-full border border-[#263247] bg-[#07101B] px-3 text-sm font-bold text-white outline-none`
|
||||
: `${RADIUS} h-11 w-full border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm font-bold text-[#0F172A] outline-none`
|
||||
}
|
||||
/>
|
||||
</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={draft.mode === "line"}
|
||||
icon={<LineChart className="h-4 w-4" />}
|
||||
label="Linha"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
mode: "line",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<ModeButton
|
||||
theme={theme}
|
||||
active={draft.mode === "area"}
|
||||
icon={<AreaChart className="h-4 w-4" />}
|
||||
label="Área"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
mode: "area",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<ModeButton
|
||||
theme={theme}
|
||||
active={draft.mode === "bar"}
|
||||
icon={<BarChart3 className="h-4 w-4" />}
|
||||
label="Barras"
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
mode: "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">
|
||||
{draft.selectedSensorKeys.length}/{maxVariables}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} mb-4 flex h-10 items-center gap-2 border border-[#263247] bg-[#07101B] px-3 text-sm text-[#7F8CA3]`
|
||||
: `${RADIUS} mb-4 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="custom-scrollbar max-h-[340px] space-y-2 overflow-y-auto pr-1">
|
||||
{filteredVariables.map((variable, index) => {
|
||||
const active = draft.selectedSensorKeys.includes(
|
||||
variable.key,
|
||||
);
|
||||
|
||||
return (
|
||||
<VariableRow
|
||||
key={variable.key}
|
||||
theme={theme}
|
||||
variable={variable}
|
||||
color={getVariableColor(index)}
|
||||
active={active}
|
||||
disabled={
|
||||
!active &&
|
||||
draft.selectedSensorKeys.length >= maxVariables
|
||||
}
|
||||
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={saveAndClose}
|
||||
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]`
|
||||
}
|
||||
>
|
||||
Guardar
|
||||
</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 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 getVariableColor(index: number) {
|
||||
const colors = [
|
||||
"#4FD1C5",
|
||||
"#3B82F6",
|
||||
"#FACC15",
|
||||
"#7DD3FC",
|
||||
"#A5B4FC",
|
||||
"#FB7185",
|
||||
];
|
||||
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { X, Minus, Maximize2, PanelTop, Cog, Square } from "lucide-react";
|
||||
import { ChartConfigModal } from "../components/ChartConfigModal";
|
||||
|
||||
import {
|
||||
WorkspaceChart,
|
||||
type WorkspaceChartConfig,
|
||||
type WorkspaceChartMode,
|
||||
type WorkspaceChartTimeRange,
|
||||
type WorkspaceChartInterval,
|
||||
} from "../../../components/charts/WorkspaceChart";
|
||||
|
||||
import { useTelemetryCatalog } from "../../telemetry/hooks/useTelemetryCatalog";
|
||||
import { useTelemetryChartSeries } from "../../telemetry/hooks/useTelemetryChartSeries";
|
||||
|
||||
import {
|
||||
useChartWorkspacePersistence,
|
||||
type PersistedChartWorkspaceItem,
|
||||
} from "../hooks/useChartWorkspacePersistence";
|
||||
|
||||
type ChartWorkspaceItem = PersistedChartWorkspaceItem & {
|
||||
hidden?: boolean;
|
||||
collapsed?: boolean;
|
||||
detached?: boolean;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
mode: WorkspaceChartMode;
|
||||
selectedSensorKeys: string[];
|
||||
hiddenSensorKeys?: string[];
|
||||
timeRange: WorkspaceChartTimeRange;
|
||||
interval: WorkspaceChartInterval;
|
||||
};
|
||||
|
||||
type ChartWindowPageProps = {
|
||||
theme: "dark" | "light";
|
||||
};
|
||||
|
||||
export function ChartWindowPage({ theme }: ChartWindowPageProps) {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
const chartId = parts[parts.length - 1] ?? "";
|
||||
|
||||
const { chartableVariables, connected } = useTelemetryCatalog();
|
||||
|
||||
const [charts, setCharts] = useState<ChartWorkspaceItem[]>([]);
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
|
||||
const currentWindow = getCurrentWindow();
|
||||
|
||||
const latestChartRef = useRef<ChartWorkspaceItem | null>(null);
|
||||
|
||||
useChartWorkspacePersistence({
|
||||
scope: "GLOBAL",
|
||||
layoutMode: "fourGrid",
|
||||
charts,
|
||||
onLoaded: (workspace) => {
|
||||
const loadedCharts = workspace.charts as ChartWorkspaceItem[];
|
||||
setCharts(loadedCharts);
|
||||
|
||||
latestChartRef.current =
|
||||
loadedCharts.find((item) => item.id === chartId) ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
const chart = charts.find((item) => item.id === chartId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
latestChartRef.current = chart;
|
||||
}, [chart]);
|
||||
|
||||
const emitMainUpdate = async (patch: Partial<ChartWorkspaceItem>) => {
|
||||
await emit("maincharts://update-chart", {
|
||||
chartId,
|
||||
patch,
|
||||
});
|
||||
};
|
||||
|
||||
const emitMainHidden = async () => {
|
||||
await emit("maincharts://hide-chart", {
|
||||
chartId,
|
||||
chart: latestChartRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
const emitMainAttached = async () => {
|
||||
await emit("maincharts://attach-chart", {
|
||||
chartId,
|
||||
chart: latestChartRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenPromise = currentWindow.onCloseRequested(async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await emitMainHidden();
|
||||
} finally {
|
||||
await currentWindow.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unlistenPromise.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [chartId]);
|
||||
|
||||
const applyLocalPatch = (patch: Partial<ChartWorkspaceItem>) => {
|
||||
setCharts((current) =>
|
||||
current.map((item) => {
|
||||
if (item.id !== chartId) return item;
|
||||
|
||||
const next = {
|
||||
...item,
|
||||
...patch,
|
||||
};
|
||||
|
||||
latestChartRef.current = next;
|
||||
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const updateChart = (patch: Partial<ChartWorkspaceItem>) => {
|
||||
applyLocalPatch(patch);
|
||||
|
||||
void emitMainUpdate(patch).catch((error) => {
|
||||
console.error("Failed to sync chart update to main window", error);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleVariable = (variableKey: string) => {
|
||||
const currentChart = latestChartRef.current;
|
||||
if (!currentChart) return;
|
||||
|
||||
const hiddenSensorKeys = currentChart.hiddenSensorKeys ?? [];
|
||||
const isHidden = hiddenSensorKeys.includes(variableKey);
|
||||
|
||||
const nextHiddenSensorKeys = isHidden
|
||||
? hiddenSensorKeys.filter((key) => key !== variableKey)
|
||||
: [...hiddenSensorKeys, variableKey];
|
||||
|
||||
updateChart({
|
||||
hiddenSensorKeys: nextHiddenSensorKeys,
|
||||
});
|
||||
};
|
||||
|
||||
const { seriesByKey, loading } = useTelemetryChartSeries(
|
||||
chart?.selectedSensorKeys ?? [],
|
||||
chart?.timeRange ?? "24h",
|
||||
chart?.interval ?? "5m",
|
||||
);
|
||||
|
||||
const selectedVariables = useMemo(
|
||||
() =>
|
||||
chartableVariables.filter((variable) =>
|
||||
chart?.selectedSensorKeys.includes(variable.key),
|
||||
),
|
||||
[chart?.selectedSensorKeys, chartableVariables],
|
||||
);
|
||||
|
||||
const attachAndClose = async () => {
|
||||
try {
|
||||
await emitMainAttached();
|
||||
} catch (error) {
|
||||
console.error("Failed to attach chart", error);
|
||||
}
|
||||
|
||||
await currentWindow.destroy();
|
||||
};
|
||||
|
||||
const closeAndHide = async () => {
|
||||
try {
|
||||
await emitMainHidden();
|
||||
} catch (error) {
|
||||
console.error("Failed to hide chart", error);
|
||||
}
|
||||
|
||||
await currentWindow.destroy();
|
||||
};
|
||||
|
||||
if (!chart) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-[#07101B] text-sm font-bold text-[#7F8CA3]">
|
||||
Gráfico não encontrado.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const variablesStillResolving =
|
||||
chart.selectedSensorKeys.length > 0 &&
|
||||
selectedVariables.length === 0;
|
||||
|
||||
const chartConfig: WorkspaceChartConfig = {
|
||||
id: chart.id,
|
||||
title: "",
|
||||
subtitle: "",
|
||||
icon: Maximize2,
|
||||
status: connected ? "online" : "offline",
|
||||
sourceLabel: connected ? "Telemetry" : "Offline",
|
||||
mode: chart.mode,
|
||||
timeRange: chart.timeRange,
|
||||
interval: chart.interval,
|
||||
variables: chartableVariables
|
||||
.filter((variable) =>
|
||||
chart.selectedSensorKeys.includes(variable.key),
|
||||
)
|
||||
.map((variable, index) => ({
|
||||
key: variable.key,
|
||||
label: variable.label,
|
||||
unit: variable.unit,
|
||||
color: getVariableColor(index),
|
||||
data: seriesByKey[variable.key] ?? [],
|
||||
visible: !(chart.hiddenSensorKeys ?? []).includes(variable.key),
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-[#07101B] text-white">
|
||||
<header className="flex h-9 shrink-0 items-center bg-[#1F232A] text-[#D7DEE8]">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-full flex-1 items-center px-3 text-xs font-semibold"
|
||||
>
|
||||
{chart.title}
|
||||
</div>
|
||||
|
||||
<div className="flex h-full items-center">
|
||||
<button
|
||||
type="button"
|
||||
title="Configurar"
|
||||
onClick={() => setConfigOpen(true)}
|
||||
className="grid h-9 w-11 place-items-center text-[#A8B3C7] transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Cog className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Repor no grid"
|
||||
onClick={attachAndClose}
|
||||
className="grid h-9 w-11 place-items-center text-[#A8B3C7] transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<PanelTop className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Minimizar"
|
||||
onClick={() => currentWindow.minimize()}
|
||||
className="grid h-9 w-11 place-items-center text-[#A8B3C7] transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Maximizar"
|
||||
onClick={() => currentWindow.toggleMaximize()}
|
||||
className="grid h-9 w-11 place-items-center text-[#A8B3C7] transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Fechar"
|
||||
onClick={closeAndHide}
|
||||
className="grid h-9 w-11 place-items-center text-[#A8B3C7] transition hover:bg-red-500 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="min-h-0 flex-1 bg-[#07101B] [&>section]:h-full [&>section]:rounded-none [&>section]:border-0">
|
||||
<WorkspaceChart
|
||||
theme={theme}
|
||||
chart={chartConfig}
|
||||
detached
|
||||
loading={loading || variablesStillResolving}
|
||||
configuredVariableCount={chart.selectedSensorKeys.length}
|
||||
onModeChange={(mode) => updateChart({ mode })}
|
||||
onVariableToggle={toggleVariable}
|
||||
onTimeRangeChange={(timeRange) => updateChart({ timeRange })}
|
||||
onIntervalChange={(interval) => updateChart({ interval })}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{configOpen && (
|
||||
<ChartConfigModal
|
||||
theme={theme}
|
||||
chart={chart}
|
||||
variables={chartableVariables}
|
||||
onClose={() => setConfigOpen(false)}
|
||||
onSave={async (updatedChart) => {
|
||||
setCharts((current) =>
|
||||
current.map((item) =>
|
||||
item.id === updatedChart.id
|
||||
? { ...item, ...updatedChart }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
|
||||
latestChartRef.current = {
|
||||
...chart,
|
||||
...updatedChart,
|
||||
};
|
||||
|
||||
await emit("maincharts://replace-chart", {
|
||||
chart: updatedChart,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to replace chart in main window", error);
|
||||
});
|
||||
|
||||
setConfigOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getVariableColor(index: number) {
|
||||
const colors = [
|
||||
"#4FD1C5",
|
||||
"#3B82F6",
|
||||
"#FACC15",
|
||||
"#7DD3FC",
|
||||
"#A5B4FC",
|
||||
"#FB7185",
|
||||
];
|
||||
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
export default ChartWindowPage;
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { ChartConfigModal } from "../components/ChartConfigModal";
|
||||
import { openChartWindow } from "../utils/openChartWindow";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import {
|
||||
Cog,
|
||||
Copy,
|
||||
@@ -48,8 +52,15 @@ type ChartWorkspaceItem = PersistedChartWorkspaceItem & {
|
||||
subtitle: string;
|
||||
mode: WorkspaceChartMode;
|
||||
selectedSensorKeys: string[];
|
||||
hiddenSensorKeys?: string[];
|
||||
timeRange: WorkspaceChartTimeRange;
|
||||
interval: WorkspaceChartInterval;
|
||||
detached?: boolean;
|
||||
windowX?: number;
|
||||
windowY?: number;
|
||||
windowWidth?: number;
|
||||
windowHeight?: number;
|
||||
windowZIndex?: number;
|
||||
};
|
||||
|
||||
const RADIUS = "rounded-[6px]";
|
||||
@@ -111,7 +122,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
|
||||
const visibleCharts = useMemo(() => {
|
||||
const openCharts = charts.filter(
|
||||
(chart) => !chart.hidden && !chart.collapsed,
|
||||
(chart) => !chart.hidden && !chart.collapsed && !chart.detached,
|
||||
);
|
||||
|
||||
if (layoutMode === "single") return openCharts.slice(0, 1);
|
||||
@@ -165,26 +176,33 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
}) => {
|
||||
if (!canAddMoreCharts) return;
|
||||
|
||||
const newChart: ChartWorkspaceItem = {
|
||||
id: `chart-${Date.now()}`,
|
||||
title,
|
||||
subtitle: "Gráfico personalizado de telemetria.",
|
||||
mode,
|
||||
selectedSensorKeys,
|
||||
timeRange: "24h",
|
||||
interval: "5m",
|
||||
};
|
||||
const newChartId = `chart-${Date.now()}`;
|
||||
|
||||
setCharts((current) => {
|
||||
const next = [...current, newChart];
|
||||
const visibleSlotCount = getVisibleSlotCount(layoutMode);
|
||||
const visibleOpenCount = current.filter(
|
||||
(chart) => !chart.hidden && !chart.collapsed && !chart.detached
|
||||
).length;
|
||||
|
||||
if (next.length >= 3) {
|
||||
setLayoutMode("fourGrid");
|
||||
} else if (next.length === 2) {
|
||||
setLayoutMode("twoColumns");
|
||||
const shouldAskPlacement = visibleOpenCount >= visibleSlotCount;
|
||||
|
||||
const newChart: ChartWorkspaceItem = {
|
||||
id: newChartId,
|
||||
title,
|
||||
subtitle: "Gráfico personalizado de telemetria.",
|
||||
mode,
|
||||
selectedSensorKeys,
|
||||
timeRange: "24h",
|
||||
interval: "5m",
|
||||
hidden: false,
|
||||
collapsed: shouldAskPlacement,
|
||||
};
|
||||
|
||||
if (shouldAskPlacement) {
|
||||
setPlacingChartId(newChartId);
|
||||
}
|
||||
|
||||
return next;
|
||||
return [...current, newChart];
|
||||
});
|
||||
|
||||
setNewChartOpen(false);
|
||||
@@ -228,30 +246,19 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
current.map((chart) => {
|
||||
if (chart.id !== chartId) return chart;
|
||||
|
||||
const alreadySelected = chart.selectedSensorKeys.includes(key);
|
||||
|
||||
if (!alreadySelected && chart.selectedSensorKeys.length >= MAX_VARIABLES_PER_CHART) {
|
||||
return chart;
|
||||
}
|
||||
const hiddenSensorKeys = chart.hiddenSensorKeys ?? [];
|
||||
const isHidden = hiddenSensorKeys.includes(key);
|
||||
|
||||
return {
|
||||
...chart,
|
||||
selectedSensorKeys: alreadySelected
|
||||
? chart.selectedSensorKeys.filter((item) => item !== key)
|
||||
: [...chart.selectedSensorKeys, key],
|
||||
hiddenSensorKeys: isHidden
|
||||
? hiddenSensorKeys.filter((item) => item !== key)
|
||||
: [...hiddenSensorKeys, key],
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const updateChartTitle = (chartId: string, title: string) => {
|
||||
setCharts((current) =>
|
||||
current.map((chart) =>
|
||||
chart.id === chartId ? { ...chart, title } : chart,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const loadSavedChart = (chartId: string) => {
|
||||
setCharts((current) => {
|
||||
const selected = current.find((chart) => chart.id === chartId);
|
||||
@@ -269,11 +276,18 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
setPlacingChartId(null);
|
||||
};
|
||||
|
||||
const closeChart = (chartId: string) => {
|
||||
const closeChart = async (chartId: string) => {
|
||||
await closeDetachedChartWindow(chartId);
|
||||
|
||||
setCharts((current) =>
|
||||
current.map((chart) =>
|
||||
chart.id === chartId
|
||||
? { ...chart, hidden: true, collapsed: false }
|
||||
? {
|
||||
...chart,
|
||||
detached: false,
|
||||
hidden: true,
|
||||
collapsed: false,
|
||||
}
|
||||
: chart,
|
||||
),
|
||||
);
|
||||
@@ -283,7 +297,9 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
if (placingChartId === chartId) setPlacingChartId(null);
|
||||
};
|
||||
|
||||
const deleteChart = (chartId: string) => {
|
||||
const deleteChart = async (chartId: string) => {
|
||||
await closeDetachedChartWindow(chartId);
|
||||
|
||||
setCharts((current) => current.filter((chart) => chart.id !== chartId));
|
||||
|
||||
if (configChartId === chartId) setConfigChartId(null);
|
||||
@@ -295,19 +311,26 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
if (!placingChartId || placingChartId === targetChartId) return;
|
||||
|
||||
setCharts((current) => {
|
||||
const source = current.find((chart) => chart.id === placingChartId);
|
||||
const sourceIndex = current.findIndex((chart) => chart.id === placingChartId);
|
||||
const targetIndex = current.findIndex((chart) => chart.id === targetChartId);
|
||||
|
||||
if (!source || targetIndex === -1 || !source.hidden) return current;
|
||||
if (sourceIndex === -1 || targetIndex === -1) return current;
|
||||
|
||||
const withoutSource = current.filter((chart) => chart.id !== placingChartId);
|
||||
const next = [...withoutSource];
|
||||
const next = [...current];
|
||||
const source = next[sourceIndex];
|
||||
const target = next[targetIndex];
|
||||
|
||||
next.splice(targetIndex, 0, {
|
||||
next[targetIndex] = {
|
||||
...source,
|
||||
hidden: false,
|
||||
collapsed: false,
|
||||
});
|
||||
};
|
||||
|
||||
next[sourceIndex] = {
|
||||
...target,
|
||||
hidden: false,
|
||||
collapsed: true,
|
||||
};
|
||||
|
||||
return next;
|
||||
});
|
||||
@@ -317,23 +340,195 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
|
||||
const openSavedChart = (chartId: string) => {
|
||||
const chart = charts.find((item) => item.id === chartId);
|
||||
if (!chart || !chart.hidden) return;
|
||||
if (!chart) return;
|
||||
|
||||
const openCount = charts.filter((item) => !item.hidden).length;
|
||||
const isActuallyOpen = !chart.hidden && !chart.collapsed && !chart.detached;
|
||||
if (isActuallyOpen) return;
|
||||
|
||||
if (openCount === 0) {
|
||||
const visibleSlotCount = getVisibleSlotCount(layoutMode);
|
||||
const visibleOpenCount = charts.filter(
|
||||
(item) => !item.hidden && !item.collapsed && !item.detached,
|
||||
).length;
|
||||
|
||||
if (visibleOpenCount === 0) {
|
||||
loadSavedChart(chartId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (openCount >= 2) {
|
||||
setLayoutMode("fourGrid");
|
||||
if (visibleOpenCount < visibleSlotCount) {
|
||||
loadSavedChart(chartId);
|
||||
return;
|
||||
}
|
||||
|
||||
setPlacingChartId(chartId);
|
||||
setSavedOpen(false);
|
||||
};
|
||||
|
||||
const closeDetachedChartWindow = async (chartId: string) => {
|
||||
const label = `chart-${chartId}`;
|
||||
const existing = await WebviewWindow.getByLabel(label);
|
||||
|
||||
if (!existing) return;
|
||||
|
||||
try {
|
||||
await existing.destroy();
|
||||
} catch (error) {
|
||||
console.error(`Failed to destroy detached chart window: ${label}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const detachChart = (chartId: string) => {
|
||||
setCharts((current) =>
|
||||
current.map((chart) =>
|
||||
chart.id === chartId
|
||||
? {
|
||||
...chart,
|
||||
detached: true,
|
||||
hidden: false,
|
||||
collapsed: false,
|
||||
}
|
||||
: chart,
|
||||
),
|
||||
);
|
||||
|
||||
window.setTimeout(() => {
|
||||
void openChartWindow(chartId);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const attachChart = async (chartId: string) => {
|
||||
setCharts((current) =>
|
||||
current.map((chart) =>
|
||||
chart.id === chartId
|
||||
? {
|
||||
...chart,
|
||||
detached: false,
|
||||
hidden: false,
|
||||
collapsed: false,
|
||||
}
|
||||
: chart,
|
||||
),
|
||||
);
|
||||
|
||||
await closeDetachedChartWindow(chartId);
|
||||
};
|
||||
|
||||
const moveDetachedChart = (chartId: string, x: number, y: number) => {
|
||||
setCharts((current) =>
|
||||
current.map((chart) =>
|
||||
chart.id === chartId
|
||||
? { ...chart, windowX: x, windowY: y }
|
||||
: chart,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenAttachPromise = listen<{
|
||||
chartId: string;
|
||||
chart: ChartWorkspaceItem | null;
|
||||
}>("maincharts://attach-chart", (event) => {
|
||||
const chartId = event.payload.chartId;
|
||||
const updatedChart = event.payload.chart;
|
||||
|
||||
setCharts((current) => {
|
||||
const visibleSlotCount = getVisibleSlotCount(layoutMode);
|
||||
|
||||
const visibleOpenCount = current.filter(
|
||||
(chart) =>
|
||||
!chart.hidden &&
|
||||
!chart.collapsed &&
|
||||
!chart.detached &&
|
||||
chart.id !== chartId,
|
||||
).length;
|
||||
|
||||
const shouldAskPlacement = visibleOpenCount >= visibleSlotCount;
|
||||
|
||||
if (shouldAskPlacement) {
|
||||
setPlacingChartId(chartId);
|
||||
}
|
||||
|
||||
return current.map((chart) =>
|
||||
chart.id === chartId
|
||||
? {
|
||||
...chart,
|
||||
...(updatedChart ?? {}),
|
||||
detached: false,
|
||||
hidden: false,
|
||||
collapsed: shouldAskPlacement,
|
||||
}
|
||||
: chart,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const unlistenHidePromise = listen<{
|
||||
chartId?: string;
|
||||
chart?: ChartWorkspaceItem | null;
|
||||
}>("maincharts://hide-chart", (event) => {
|
||||
const chartId = event.payload.chartId ?? event.payload.chart?.id;
|
||||
if (!chartId) return;
|
||||
|
||||
setCharts((current) =>
|
||||
current.map((chart) =>
|
||||
chart.id === chartId
|
||||
? {
|
||||
...chart,
|
||||
...(event.payload.chart ?? {}),
|
||||
detached: false,
|
||||
hidden: true,
|
||||
collapsed: false,
|
||||
}
|
||||
: chart,
|
||||
),
|
||||
);
|
||||
|
||||
setPlacingChartId((current) => (current === chartId ? null : current));
|
||||
});
|
||||
|
||||
const unlistenUpdatePromise = listen<{
|
||||
chartId: string;
|
||||
patch: Partial<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
|
||||
@@ -476,6 +671,9 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
setCharts={setCharts}
|
||||
placingChartId={placingChartId}
|
||||
placeChartHere={placeChartHere}
|
||||
detachChart={detachChart}
|
||||
attachChart={attachChart}
|
||||
moveDetachedChart={moveDetachedChart}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -487,7 +685,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
|
||||
setCharts((current) => {
|
||||
const source = current.find((chart) => chart.id === placingChartId);
|
||||
if (!source || !source.hidden) return current;
|
||||
if (!source || (!source.hidden && !source.collapsed)) return current;
|
||||
|
||||
const withoutSource = current.filter((chart) => chart.id !== placingChartId);
|
||||
return [
|
||||
@@ -536,9 +734,17 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
|
||||
chart={configChart}
|
||||
variables={chartableVariables}
|
||||
onClose={() => setConfigChartId(null)}
|
||||
onTitleChange={(title) => updateChartTitle(configChart.id, title)}
|
||||
onModeChange={(mode) => setChartMode(configChart.id, mode)}
|
||||
onVariableToggle={(key) => toggleVariable(configChart.id, key)}
|
||||
onSave={(updatedChart) => {
|
||||
setCharts((current) =>
|
||||
current.map((chart) =>
|
||||
chart.id === updatedChart.id
|
||||
? { ...chart, ...updatedChart }
|
||||
: chart,
|
||||
),
|
||||
);
|
||||
|
||||
setConfigChartId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -561,6 +767,9 @@ function WorkspaceChartContainer({
|
||||
setCharts,
|
||||
placingChartId,
|
||||
placeChartHere,
|
||||
detachChart,
|
||||
attachChart,
|
||||
moveDetachedChart
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
chartItem: ChartWorkspaceItem;
|
||||
@@ -570,13 +779,16 @@ function WorkspaceChartContainer({
|
||||
setMovingChartId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
swapCharts: (sourceId: string, targetId: string) => void;
|
||||
duplicateChart: (chartId: string) => void;
|
||||
closeChart: (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;
|
||||
|
||||
@@ -626,7 +838,7 @@ function WorkspaceChartContainer({
|
||||
unit: variable.unit,
|
||||
color: getVariableColor(index),
|
||||
data: seriesByKey[variable.key] ?? [],
|
||||
visible: true,
|
||||
visible: !(chartItem.hiddenSensorKeys ?? []).includes(variable.key),
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -654,11 +866,25 @@ function WorkspaceChartContainer({
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button type="button" title="Destacar janela" className={floatingIconClass(theme)}>
|
||||
<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={() => closeChart(chartItem.id)}>
|
||||
<button
|
||||
type="button"
|
||||
title="Fechar"
|
||||
className={floatingIconClass(theme)}
|
||||
onClick={() => void closeChart(chartItem.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -691,6 +917,9 @@ function WorkspaceChartContainer({
|
||||
chart={chartConfig}
|
||||
configuredVariableCount={chartItem.selectedSensorKeys.length}
|
||||
loading={loading || variablesStillResolving}
|
||||
detached={chartItem.detached}
|
||||
onDetach={() => detachChart(chartItem.id)}
|
||||
onAttach={() => attachChart(chartItem.id)}
|
||||
onTimeRangeChange={(range) =>
|
||||
setCharts((current) =>
|
||||
current.map((chart) =>
|
||||
@@ -743,6 +972,32 @@ function WorkspaceChartContainer({
|
||||
onVariableToggle={(variableKey) =>
|
||||
toggleVariable(chartItem.id, variableKey)
|
||||
}
|
||||
onHeaderPointerDown={
|
||||
chartItem.detached
|
||||
? (event) => {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const initialX = chartItem.windowX ?? 120;
|
||||
const initialY = chartItem.windowY ?? 120;
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
moveDetachedChart(
|
||||
chartItem.id,
|
||||
initialX + moveEvent.clientX - startX,
|
||||
initialY + moveEvent.clientY - startY,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -769,7 +1024,7 @@ function SavedChartsDropdown({
|
||||
onSearchChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onStartPlacement: (chartId: string) => void;
|
||||
onDelete: (chartId: string) => void;
|
||||
onDelete: (chartId: string) => void | Promise<void>;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
@@ -828,7 +1083,11 @@ function SavedChartsDropdown({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{chart.hidden ? (
|
||||
{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)}
|
||||
@@ -845,7 +1104,7 @@ function SavedChartsDropdown({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(chart.id)}
|
||||
onClick={() => void onDelete(chart.id)}
|
||||
title="Eliminar gráfico"
|
||||
className="text-[#7F8CA3] transition hover:text-red-400"
|
||||
>
|
||||
@@ -1109,207 +1368,6 @@ function NewChartModal({
|
||||
);
|
||||
}
|
||||
|
||||
function ChartConfigModal({
|
||||
theme,
|
||||
chart,
|
||||
variables,
|
||||
onClose,
|
||||
onTitleChange,
|
||||
onModeChange,
|
||||
onVariableToggle,
|
||||
}: {
|
||||
theme: "dark" | "light";
|
||||
chart: ChartWorkspaceItem;
|
||||
variables: ChartVariable[];
|
||||
onClose: () => void;
|
||||
onTitleChange: (title: string) => void;
|
||||
onModeChange: (mode: WorkspaceChartMode) => void;
|
||||
onVariableToggle: (key: string) => void;
|
||||
}) {
|
||||
const isDark = theme === "dark";
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredVariables = variables.filter((variable) =>
|
||||
`${variable.label} ${variable.category} ${variable.group} ${variable.unit}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
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]"
|
||||
}
|
||||
>
|
||||
Configuração do gráfico
|
||||
</p>
|
||||
|
||||
<h2 className="mt-1 text-xl font-black">{chart.title}</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={chart.title}
|
||||
onChange={(event) => onTitleChange(event.target.value)}
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} h-11 w-full border border-[#263247] bg-[#07101B] px-3 text-sm font-bold text-white outline-none`
|
||||
: `${RADIUS} h-11 w-full border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm font-bold text-[#0F172A] outline-none`
|
||||
}
|
||||
/>
|
||||
</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={chart.mode === "line"}
|
||||
icon={<LineChart className="h-4 w-4" />}
|
||||
label="Linha"
|
||||
onClick={() => onModeChange("line")}
|
||||
/>
|
||||
<ModeButton
|
||||
theme={theme}
|
||||
active={chart.mode === "area"}
|
||||
icon={<AreaChart className="h-4 w-4" />}
|
||||
label="Área"
|
||||
onClick={() => onModeChange("area")}
|
||||
/>
|
||||
<ModeButton
|
||||
theme={theme}
|
||||
active={chart.mode === "bar"}
|
||||
icon={<BarChart3 className="h-4 w-4" />}
|
||||
label="Barras"
|
||||
onClick={() => onModeChange("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">
|
||||
{chart.selectedSensorKeys.length}/{MAX_VARIABLES_PER_CHART}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div
|
||||
className={
|
||||
isDark
|
||||
? `${RADIUS} mb-4 flex h-10 items-center gap-2 border border-[#263247] bg-[#07101B] px-3 text-sm text-[#7F8CA3]`
|
||||
: `${RADIUS} mb-4 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-[340px] space-y-2 overflow-y-auto pr-1">
|
||||
{filteredVariables.map((variable, index) => {
|
||||
const active = chart.selectedSensorKeys.includes(variable.key);
|
||||
|
||||
return (
|
||||
<VariableRow
|
||||
key={variable.key}
|
||||
theme={theme}
|
||||
variable={variable}
|
||||
color={getVariableColor(index)}
|
||||
active={active}
|
||||
disabled={
|
||||
!active &&
|
||||
chart.selectedSensorKeys.length >=
|
||||
MAX_VARIABLES_PER_CHART
|
||||
}
|
||||
onClick={() => onVariableToggle(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]`
|
||||
}
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableRow({
|
||||
theme,
|
||||
variable,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
export async function openChartWindow(chartId: string) {
|
||||
const label = `chart-${chartId}`;
|
||||
|
||||
const existing = await WebviewWindow.getByLabel(label);
|
||||
|
||||
if (existing) {
|
||||
await existing.show();
|
||||
await existing.setFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
const chartWindow = new WebviewWindow(label, {
|
||||
url: `/chart-window/${chartId}`,
|
||||
|
||||
title: "Chart",
|
||||
|
||||
width: 920,
|
||||
height: 680,
|
||||
|
||||
minWidth: 720,
|
||||
minHeight: 480,
|
||||
|
||||
decorations: false,
|
||||
transparent: false,
|
||||
|
||||
resizable: true,
|
||||
maximizable: true,
|
||||
minimizable: true,
|
||||
closable: true,
|
||||
|
||||
visible: true,
|
||||
focus: true,
|
||||
center: true,
|
||||
|
||||
skipTaskbar: false,
|
||||
});
|
||||
|
||||
chartWindow.once("tauri://created", () => {
|
||||
console.log(`Chart window created: ${label}`);
|
||||
});
|
||||
|
||||
chartWindow.once("tauri://error", (error) => {
|
||||
console.error(`Failed to create chart window: ${label}`, error);
|
||||
});
|
||||
|
||||
return chartWindow;
|
||||
}
|
||||
+4
-12
@@ -20,7 +20,7 @@ body {
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(56, 189, 248, 0.45) rgba(15, 23, 42, 0.35);
|
||||
scrollbar-color: rgba(71, 85, 105, 0.9) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
@@ -34,19 +34,11 @@ body {
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(56, 189, 248, 0.7),
|
||||
rgba(14, 165, 233, 0.35)
|
||||
);
|
||||
background: rgba(71, 85, 105, 0.75);
|
||||
border: 2px solid rgba(15, 23, 42, 0.9);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(56, 189, 248, 0.95),
|
||||
rgba(14, 165, 233, 0.55)
|
||||
);
|
||||
}
|
||||
background: rgba(100, 116, 139, 0.95);
|
||||
}
|
||||
Reference in New Issue
Block a user