1142 lines
44 KiB
TypeScript
1142 lines
44 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode, type RefObject } from "react";
|
|
import {
|
|
ChevronDown,
|
|
FolderPlus,
|
|
LocateFixed,
|
|
Move,
|
|
Plus,
|
|
RefreshCw,
|
|
Save,
|
|
Search,
|
|
Settings2,
|
|
X,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
} from "lucide-react";
|
|
|
|
import satelliteImage from "../../../assets/sattelite169.png";
|
|
import { useSynopticVariables } from "../hooks/useSynopticVariables";
|
|
import type { SynopticVariable } from "../hooks/useSynopticVariables";
|
|
|
|
type SynopticPageProps = {
|
|
theme: "dark" | "light";
|
|
};
|
|
|
|
type CardVariant = "compact" | "large";
|
|
|
|
type MapItem = {
|
|
id: string;
|
|
variableId: string;
|
|
x: number;
|
|
y: number;
|
|
variant: CardVariant;
|
|
showLabel: boolean;
|
|
};
|
|
|
|
const initialMapItems: MapItem[] = [];
|
|
|
|
export function SynopticPage({ theme }: SynopticPageProps) {
|
|
const isDark = theme === "dark";
|
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
|
const synoptic = useSynopticVariables();
|
|
|
|
const [items, setItems] = useState<MapItem[]>(initialMapItems);
|
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
|
const [activeGroup, setActiveGroup] = useState<string>("Todas");
|
|
const [search, setSearch] = useState("");
|
|
|
|
const [pendingVariableDrag, setPendingVariableDrag] = useState<{
|
|
id: string;
|
|
pointerId: number;
|
|
} | null>(null);
|
|
|
|
const [dragState, setDragState] = useState<{
|
|
id: string;
|
|
pointerId: number;
|
|
offsetX: number;
|
|
offsetY: number;
|
|
} | null>(null);
|
|
|
|
const variablesById = useMemo(
|
|
() =>
|
|
new Map(
|
|
synoptic.variables.map((variable) => [
|
|
variable.id,
|
|
variable,
|
|
]),
|
|
),
|
|
[synoptic.variables],
|
|
);
|
|
|
|
const selectedItem =
|
|
items.find((item) => item.id === selectedItemId) ??
|
|
null;
|
|
|
|
const selectedVariable =
|
|
selectedItem ? variablesById.get(selectedItem.variableId) ?? null : null;
|
|
|
|
|
|
const placedVariableIds = useMemo(
|
|
() => new Set(items.map((item) => item.variableId)),
|
|
[items],
|
|
);
|
|
|
|
const visiblePlacedVariableIds = useMemo(() => {
|
|
const ids = new Set(placedVariableIds);
|
|
|
|
if (pendingVariableDrag) {
|
|
const pendingItem = items.find((item) => item.id === pendingVariableDrag.id);
|
|
|
|
if (pendingItem) {
|
|
ids.delete(pendingItem.variableId);
|
|
}
|
|
}
|
|
|
|
return ids;
|
|
}, [items, placedVariableIds, pendingVariableDrag]);
|
|
|
|
const availableGroups = useMemo<string[]>(
|
|
() =>
|
|
Array.from(
|
|
new Set(
|
|
synoptic.variables
|
|
.filter((variable) => !visiblePlacedVariableIds.has(variable.id))
|
|
.map((variable) => variable.group),
|
|
),
|
|
),
|
|
[synoptic.variables, visiblePlacedVariableIds],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (activeGroup !== "Todas" && !availableGroups.includes(activeGroup)) {
|
|
setActiveGroup("Todas");
|
|
}
|
|
}, [activeGroup, availableGroups]);
|
|
|
|
function getPointInCanvas(event: PointerEvent) {
|
|
const rect = canvasRef.current?.getBoundingClientRect();
|
|
|
|
if (!rect) {
|
|
return null;
|
|
}
|
|
|
|
const x = ((event.clientX - rect.left) / rect.width) * 100;
|
|
const y = ((event.clientY - rect.top) / rect.height) * 100;
|
|
|
|
if (x < 0 || x > 100 || y < 0 || y > 100) {
|
|
return null;
|
|
}
|
|
|
|
return { x, y };
|
|
}
|
|
|
|
function handleCardPointerDown(event: PointerEvent<HTMLDivElement>, item: MapItem) {
|
|
const point = getPointInCanvas(event);
|
|
|
|
if (!point) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
|
|
setSelectedItemId(item.id);
|
|
|
|
setDragState({
|
|
id: item.id,
|
|
pointerId: event.pointerId,
|
|
offsetX: point.x - item.x,
|
|
offsetY: point.y - item.y,
|
|
});
|
|
}
|
|
|
|
function handleCardPointerMove(event: PointerEvent<HTMLDivElement>) {
|
|
if (!dragState || event.pointerId !== dragState.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const point = getPointInCanvas(event);
|
|
|
|
if (!point) {
|
|
return;
|
|
}
|
|
|
|
setItems((currentItems) =>
|
|
currentItems.map((item) =>
|
|
item.id === dragState.id
|
|
? {
|
|
...item,
|
|
x: clampPercent(point.x - dragState.offsetX),
|
|
y: clampPercent(point.y - dragState.offsetY),
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
}
|
|
|
|
function handleCardPointerUp(event: PointerEvent<HTMLDivElement>) {
|
|
if (dragState && event.pointerId === dragState.pointerId) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
setDragState(null);
|
|
}
|
|
}
|
|
|
|
function createMapItemFromVariable(variable: SynopticVariable, point: { x: number; y: number }) {
|
|
const newItem: MapItem = {
|
|
id: `map-item-${crypto.randomUUID()}`,
|
|
variableId: variable.id,
|
|
x: clampPercent(point.x),
|
|
y: clampPercent(point.y),
|
|
variant: "compact",
|
|
showLabel: true,
|
|
};
|
|
|
|
setItems((currentItems) => [...currentItems, newItem]);
|
|
setSelectedItemId(newItem.id);
|
|
|
|
return newItem;
|
|
}
|
|
|
|
function handleVariablePointerDown(
|
|
event: PointerEvent<HTMLDivElement>,
|
|
variable: SynopticVariable,
|
|
) {
|
|
event.preventDefault();
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
|
|
const point = getPointInCanvas(event) ?? {
|
|
x: 50,
|
|
y: 50,
|
|
};
|
|
|
|
const newItem = createMapItemFromVariable(variable, point);
|
|
|
|
setPendingVariableDrag({
|
|
id: newItem.id,
|
|
pointerId: event.pointerId,
|
|
});
|
|
|
|
setDragState({
|
|
id: newItem.id,
|
|
pointerId: event.pointerId,
|
|
offsetX: 0,
|
|
offsetY: 0,
|
|
});
|
|
}
|
|
|
|
function handleVariablePointerMove(event: PointerEvent<HTMLDivElement>) {
|
|
if (!pendingVariableDrag || event.pointerId !== pendingVariableDrag.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const point = getPointInCanvas(event);
|
|
|
|
if (!point) {
|
|
return;
|
|
}
|
|
|
|
setItems((currentItems) =>
|
|
currentItems.map((item) =>
|
|
item.id === pendingVariableDrag.id
|
|
? {
|
|
...item,
|
|
x: clampPercent(point.x),
|
|
y: clampPercent(point.y),
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
}
|
|
|
|
function handleVariablePointerUp(event: PointerEvent<HTMLDivElement>) {
|
|
if (!pendingVariableDrag || event.pointerId !== pendingVariableDrag.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const point = getPointInCanvas(event);
|
|
|
|
if (!point) {
|
|
setItems((currentItems) =>
|
|
currentItems.filter((item) => item.id !== pendingVariableDrag.id),
|
|
);
|
|
|
|
if (selectedItemId === pendingVariableDrag.id) {
|
|
setSelectedItemId(null);
|
|
}
|
|
}
|
|
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
}
|
|
|
|
setPendingVariableDrag(null);
|
|
setDragState(null);
|
|
}
|
|
|
|
function handleRemoveSelectedItem() {
|
|
if (!selectedItemId) {
|
|
return;
|
|
}
|
|
|
|
setItems((currentItems) =>
|
|
currentItems.filter((item) => item.id !== selectedItemId),
|
|
);
|
|
|
|
setSelectedItemId(null);
|
|
}
|
|
|
|
function handleSelectedVariantChange(variant: CardVariant) {
|
|
if (!selectedItemId) {
|
|
return;
|
|
}
|
|
|
|
setItems((currentItems) =>
|
|
currentItems.map((item) =>
|
|
item.id === selectedItemId
|
|
? {
|
|
...item,
|
|
variant,
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
}
|
|
|
|
function handleSelectedShowLabelChange(showLabel: boolean) {
|
|
if (!selectedItemId) {
|
|
return;
|
|
}
|
|
|
|
setItems((currentItems) =>
|
|
currentItems.map((item) =>
|
|
item.id === selectedItemId
|
|
? {
|
|
...item,
|
|
showLabel,
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
}
|
|
|
|
useEffect(() => {
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if (
|
|
event.key !== "Delete" &&
|
|
event.key !== "Backspace"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!selectedItemId) {
|
|
return;
|
|
}
|
|
|
|
const activeElement = document.activeElement;
|
|
|
|
const typing =
|
|
activeElement instanceof HTMLInputElement ||
|
|
activeElement instanceof HTMLTextAreaElement ||
|
|
activeElement instanceof HTMLSelectElement;
|
|
|
|
if (typing) {
|
|
return;
|
|
}
|
|
|
|
setItems((currentItems) =>
|
|
currentItems.filter(
|
|
(item) => item.id !== selectedItemId,
|
|
),
|
|
);
|
|
|
|
setSelectedItemId(null);
|
|
}
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [selectedItemId]);
|
|
|
|
return (
|
|
<div className={isDark ? "h-full text-white" : "h-full text-[#0F172A]"}>
|
|
<div className="flex h-[calc(100vh-104px)] min-h-0 min-w-0 flex-col gap-2 overflow-hidden min-[1220px]:gap-3">
|
|
<WorkspaceBar isDark={isDark} />
|
|
|
|
<main className="grid min-h-0 min-w-0 flex-1 grid-cols-[180px_minmax(0,1fr)_210px] gap-2 overflow-hidden min-[1180px]:grid-cols-[200px_minmax(0,1fr)_230px] min-[1320px]:grid-cols-[230px_minmax(0,1fr)_260px] min-[1450px]:grid-cols-[270px_minmax(0,1fr)_290px] min-[1220px]:gap-3">
|
|
<VariablesPanel
|
|
isDark={isDark}
|
|
variables={synoptic.variables}
|
|
placedVariableIds={visiblePlacedVariableIds}
|
|
availableGroups={availableGroups}
|
|
activeGroup={activeGroup}
|
|
search={search}
|
|
onActiveGroupChange={setActiveGroup}
|
|
onSearchChange={setSearch}
|
|
onVariablePointerDown={handleVariablePointerDown}
|
|
onVariablePointerMove={handleVariablePointerMove}
|
|
onVariablePointerUp={handleVariablePointerUp}
|
|
/>
|
|
|
|
<section className={isDark ? "relative min-h-0 min-w-0 overflow-hidden rounded-[8px] bg-[#0E1726]" : "relative min-h-0 min-w-0 overflow-hidden rounded-[8px] border border-[#D7DEE8] bg-white"}>
|
|
<Toolbar isDark={isDark} />
|
|
|
|
<CanvasMock
|
|
isDark={isDark}
|
|
canvasRef={canvasRef}
|
|
items={items}
|
|
variablesById={variablesById}
|
|
selectedItemId={selectedItemId}
|
|
draggingItemId={dragState?.id}
|
|
onCardPointerDown={handleCardPointerDown}
|
|
onCardPointerMove={handleCardPointerMove}
|
|
onCardPointerUp={handleCardPointerUp}
|
|
/>
|
|
</section>
|
|
|
|
<PropertiesPanel
|
|
isDark={isDark}
|
|
selectedItem={selectedItem}
|
|
selectedVariable={selectedVariable}
|
|
onRemoveSelectedItem={handleRemoveSelectedItem}
|
|
onVariantChange={handleSelectedVariantChange}
|
|
onShowLabelChange={handleSelectedShowLabelChange}
|
|
/>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkspaceBar({ isDark }: { isDark: boolean }) {
|
|
return (
|
|
<section className="flex h-[52px] shrink-0 items-center justify-between gap-3 bg-transparent min-[1220px]:h-[58px]">
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-black uppercase leading-none tracking-[0.28em] text-[#18B8A6]">
|
|
Workspace sinótico
|
|
</p>
|
|
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className={isDark ? "flex min-w-0 items-center gap-2 text-[15px] font-black leading-none tracking-[-0.03em] text-white" : "flex min-w-0 items-center gap-2 text-[15px] font-black leading-none tracking-[-0.03em] text-[#0F172A]"}
|
|
>
|
|
<span className="truncate">Estufa Principal</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 text-[#7F8CA3]" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex min-w-0 items-center gap-2 overflow-x-auto pb-1">
|
|
<WorkspaceButton isDark={isDark} icon={<FolderPlus className="h-4 w-4" />} label="Novo" />
|
|
<WorkspaceButton isDark={isDark} icon={<Save className="h-4 w-4" />} label="Guardar" primary />
|
|
<WorkspaceButton isDark={isDark} icon={<RefreshCw className="h-4 w-4" />} label="Atualizar" />
|
|
|
|
<div className={isDark ? "ml-1 flex h-9 shrink-0 items-center gap-2 rounded-[6px] border border-[#263247] bg-[#0E1726] px-3 text-xs font-black text-[#A8B3C7] min-[1220px]:ml-3 min-[1220px]:px-4" : "ml-1 flex h-9 shrink-0 items-center gap-2 rounded-[6px] border border-[#D7DEE8] bg-white px-3 text-xs font-black text-slate-600 min-[1220px]:ml-3 min-[1220px]:px-4"}>
|
|
<span className="h-2 w-2 rounded-full bg-[#18B8A6]" />
|
|
Alterações guardadas
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function WorkspaceButton({
|
|
isDark,
|
|
icon,
|
|
label,
|
|
primary,
|
|
}: {
|
|
isDark: boolean;
|
|
icon: ReactNode;
|
|
label: string;
|
|
primary?: boolean;
|
|
}) {
|
|
if (primary) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="flex h-9 shrink-0 items-center gap-2 rounded-[6px] bg-[#18B8A6] px-3 text-xs font-black text-white transition hover:bg-[#21C7B5]"
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={
|
|
isDark
|
|
? "flex h-9 shrink-0 items-center gap-2 rounded-[6px] border border-[#263247] bg-[#0E1726] px-3 text-xs font-black text-[#D7DEE8] transition hover:border-[#36506D] hover:text-white"
|
|
: "flex h-9 shrink-0 items-center gap-2 rounded-[6px] border border-[#D7DEE8] bg-white px-3 text-xs font-black text-slate-700 transition hover:bg-[#F8FAFC] hover:text-[#0F172A]"
|
|
}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function VariablesPanel({
|
|
isDark,
|
|
variables,
|
|
placedVariableIds,
|
|
availableGroups,
|
|
activeGroup,
|
|
search,
|
|
onActiveGroupChange,
|
|
onSearchChange,
|
|
onVariablePointerDown,
|
|
onVariablePointerMove,
|
|
onVariablePointerUp,
|
|
}: {
|
|
isDark: boolean;
|
|
variables: SynopticVariable[];
|
|
placedVariableIds: Set<string>;
|
|
availableGroups: string[];
|
|
activeGroup: string;
|
|
search: string;
|
|
onActiveGroupChange: (group: string) => void;
|
|
onSearchChange: (value: string) => void;
|
|
onVariablePointerDown: (
|
|
event: PointerEvent<HTMLDivElement>,
|
|
variable: SynopticVariable,
|
|
) => void;
|
|
onVariablePointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
|
onVariablePointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
|
}) {
|
|
const groups = availableGroups;
|
|
const tabs = ["Todas", ...availableGroups];
|
|
|
|
const filteredVariables = variables.filter((variable) => {
|
|
if (placedVariableIds.has(variable.id)) {
|
|
return false;
|
|
}
|
|
|
|
const matchesGroup =
|
|
activeGroup === "Todas" ||
|
|
variable.group === activeGroup;
|
|
|
|
const query = search.trim().toLowerCase();
|
|
|
|
const matchesSearch =
|
|
query.length === 0 ||
|
|
`${variable.label} ${variable.key} ${variable.unit}`
|
|
.toLowerCase()
|
|
.includes(query);
|
|
|
|
return matchesGroup && matchesSearch;
|
|
});
|
|
|
|
return (
|
|
<aside
|
|
className={
|
|
isDark
|
|
? "flex min-h-0 min-w-0 flex-col rounded-[8px] border border-[#263247] bg-[#0E1726]"
|
|
: "flex min-h-0 min-w-0 flex-col rounded-[8px] border border-[#D7DEE8] bg-white"
|
|
}
|
|
>
|
|
<div className={isDark ? "flex h-12 shrink-0 items-center justify-between border-b border-[#263247]/70 px-3 min-[1450px]:px-4" : "flex h-12 shrink-0 items-center justify-between border-b border-[#D7DEE8] px-3 min-[1450px]:px-4"}>
|
|
<div>
|
|
<h2 className="text-base font-black">Variáveis</h2>
|
|
<p className="text-[11px] font-bold text-[#7F8CA3]">
|
|
Arraste para a imagem
|
|
</p>
|
|
</div>
|
|
|
|
<X className="h-4 w-4 text-[#7F8CA3]" />
|
|
</div>
|
|
|
|
<div className="space-y-3 p-3">
|
|
<div className="flex items-center gap-3 overflow-x-auto text-xs font-bold min-[1450px]:gap-4">
|
|
{tabs.map((item) => (
|
|
<button
|
|
key={item}
|
|
type="button"
|
|
onClick={() => onActiveGroupChange(item)}
|
|
className={
|
|
activeGroup === item
|
|
? "shrink-0 border-b-2 border-[#18B8A6] pb-2 text-[#18B8A6]"
|
|
: isDark
|
|
? "shrink-0 pb-2 text-[#7F8CA3] transition hover:text-white"
|
|
: "shrink-0 pb-2 text-slate-500 transition hover:text-[#0F172A]"
|
|
}
|
|
>
|
|
{item}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
className={
|
|
isDark
|
|
? "flex h-9 min-w-0 items-center gap-2 rounded-[6px] border border-[#263247] bg-[#07101B] px-3 text-xs text-[#7F8CA3]"
|
|
: "flex h-9 min-w-0 items-center gap-2 rounded-[6px] border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-xs text-slate-500"
|
|
}
|
|
>
|
|
<Search className="h-4 w-4 shrink-0" />
|
|
<input
|
|
value={search}
|
|
onChange={(event) => onSearchChange(event.target.value)}
|
|
placeholder="Procurar variável..."
|
|
className="min-w-0 flex-1 bg-transparent font-bold text-inherit outline-none placeholder:text-[#7F8CA3]"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 pb-3">
|
|
{groups.map((group) => {
|
|
const groupVariables = filteredVariables.filter(
|
|
(variable) => variable.group === group,
|
|
);
|
|
|
|
if (activeGroup !== "Todas" && activeGroup !== group) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={group}
|
|
className={
|
|
isDark
|
|
? "overflow-hidden rounded-[7px] border border-[#1C2A3D] bg-[#111A2B]"
|
|
: "overflow-hidden rounded-[7px] border border-[#E2E8F0] bg-[#F8FAFC]"
|
|
}
|
|
>
|
|
<div
|
|
className={
|
|
isDark
|
|
? "flex h-8 items-center justify-between bg-white/[0.03] px-3 text-sm font-black"
|
|
: "flex h-8 items-center justify-between bg-white px-3 text-sm font-black"
|
|
}
|
|
>
|
|
<span>{group}</span>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-black text-[#7F8CA3]">
|
|
{groupVariables.length}
|
|
</span>
|
|
<ChevronDown className="h-4 w-4 text-[#7F8CA3]" />
|
|
</div>
|
|
</div>
|
|
|
|
{groupVariables.length === 0 ? (
|
|
<div className={isDark ? "border-t border-[#1C2A3D] px-3 py-3 text-[11px] font-bold text-[#7F8CA3]" : "border-t border-[#E2E8F0] px-3 py-3 text-[11px] font-bold text-slate-500"}>
|
|
Sem variáveis disponíveis
|
|
</div>
|
|
) : (
|
|
groupVariables.map((variable) => {
|
|
const Icon = variable.icon;
|
|
|
|
return (
|
|
<div
|
|
key={variable.id}
|
|
onPointerDown={(event) => onVariablePointerDown(event, variable)}
|
|
onPointerMove={onVariablePointerMove}
|
|
onPointerUp={onVariablePointerUp}
|
|
onPointerCancel={onVariablePointerUp}
|
|
className={
|
|
isDark
|
|
? "flex h-9 cursor-grab items-center gap-2 border-t border-[#1C2A3D] px-3 text-[11px] transition hover:bg-white/[0.04] active:cursor-grabbing"
|
|
: "flex h-9 cursor-grab items-center gap-2 border-t border-[#E2E8F0] px-3 text-[11px] transition hover:bg-white active:cursor-grabbing"
|
|
}
|
|
>
|
|
<span
|
|
className="shrink-0"
|
|
style={{ color: variable.color }}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
</span>
|
|
|
|
<span className="min-w-0 flex-1 truncate font-bold">
|
|
{variable.label}
|
|
</span>
|
|
|
|
<span className="shrink-0 font-black">
|
|
{formatVariableValue(variable)}
|
|
</span>
|
|
|
|
<span
|
|
className={
|
|
variable.connected
|
|
? "h-2 w-2 shrink-0 rounded-full bg-[#22C55E]"
|
|
: "h-2 w-2 shrink-0 rounded-full bg-[#7F8CA3]"
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div
|
|
className={
|
|
isDark
|
|
? "m-3 rounded-[7px] border border-[#263247] bg-[#111A2B] p-3 text-center"
|
|
: "m-3 rounded-[7px] border border-[#D7DEE8] bg-[#F8FAFC] p-3 text-center"
|
|
}
|
|
>
|
|
<p className="text-xs font-black">Drag & drop</p>
|
|
<p className="mt-1 text-[11px] text-[#7F8CA3]">
|
|
Arraste uma variável real para criar um cartão no mapa.
|
|
</p>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function Toolbar({ isDark }: { isDark: boolean }) {
|
|
const tools = [
|
|
{ label: "Selecionar", icon: <LocateFixed className="h-4 w-4" />, active: true },
|
|
{ label: "Mover", icon: <Move className="h-4 w-4" /> },
|
|
{ label: "Texto", icon: <span className="text-sm font-black">T</span> },
|
|
];
|
|
|
|
return (
|
|
<div className="absolute left-3 right-3 top-3 z-20 flex items-start justify-between gap-2 min-[1450px]:left-4 min-[1450px]:right-4 min-[1450px]:top-4">
|
|
<div className={isDark ? "flex max-w-[calc(100%-88px)] items-center gap-1 overflow-x-auto rounded-[8px] border border-[#263247] bg-[#07101B]/92 p-1 shadow-xl backdrop-blur" : "flex max-w-[calc(100%-88px)] items-center gap-1 overflow-x-auto rounded-[8px] border border-[#D7DEE8] bg-white/92 p-1 shadow-xl backdrop-blur"}>
|
|
{tools.map((tool) => (
|
|
<button
|
|
key={tool.label}
|
|
className={
|
|
tool.active
|
|
? "flex h-9 shrink-0 items-center gap-2 rounded-[6px] border border-[#18B8A6] bg-[#18B8A6]/15 px-3 text-xs font-black text-[#18B8A6] min-[1450px]:px-4"
|
|
: isDark
|
|
? "flex h-9 shrink-0 items-center gap-2 rounded-[6px] px-3 text-xs font-bold text-[#A8B3C7] transition hover:bg-white/10 hover:text-white min-[1450px]:px-4"
|
|
: "flex h-9 shrink-0 items-center gap-2 rounded-[6px] px-3 text-xs font-bold text-slate-600 transition hover:bg-[#F1F5F9] hover:text-[#0F172A] min-[1450px]:px-4"
|
|
}
|
|
>
|
|
{tool.icon}
|
|
<span className="hidden min-[1120px]:inline">{tool.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className={isDark ? "flex shrink-0 items-center gap-1 rounded-[8px] border border-[#263247] bg-[#07101B]/92 p-1 shadow-xl backdrop-blur" : "flex shrink-0 items-center gap-1 rounded-[8px] border border-[#D7DEE8] bg-white/92 p-1 shadow-xl backdrop-blur"}>
|
|
<button className={smallToolClass(isDark)}>
|
|
<Settings2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CanvasMock({
|
|
isDark,
|
|
canvasRef,
|
|
items,
|
|
variablesById,
|
|
selectedItemId,
|
|
draggingItemId,
|
|
onCardPointerDown,
|
|
onCardPointerMove,
|
|
onCardPointerUp,
|
|
}: {
|
|
isDark: boolean;
|
|
canvasRef: RefObject<HTMLDivElement | null>;
|
|
items: MapItem[];
|
|
variablesById: Map<string, SynopticVariable>;
|
|
selectedItemId: string | null;
|
|
draggingItemId?: string;
|
|
onCardPointerDown: (event: PointerEvent<HTMLDivElement>, item: MapItem) => void;
|
|
onCardPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
|
onCardPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
|
}) {
|
|
return (
|
|
<div className={isDark ? "flex h-full w-full items-center justify-center overflow-hidden bg-[#07101B] p-2" : "flex h-full w-full items-center justify-center overflow-hidden bg-[#F8FAFC] p-2"}>
|
|
<div
|
|
ref={canvasRef}
|
|
className="relative h-full max-h-full max-w-full overflow-hidden rounded-[6px]"
|
|
style={{ aspectRatio: "16 / 9" }}
|
|
>
|
|
<img
|
|
src={satelliteImage}
|
|
alt="Vista aérea da instalação"
|
|
className="absolute inset-0 h-full w-full select-none object-fill"
|
|
draggable={false}
|
|
/>
|
|
|
|
{items.length === 0 && (
|
|
<div className={isDark ? "pointer-events-none absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-[8px] border border-dashed border-[#18B8A6]/50 bg-[#07101B]/80 px-5 py-4 text-center shadow-xl" : "pointer-events-none absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-[8px] border border-dashed border-[#18B8A6]/60 bg-white/85 px-5 py-4 text-center shadow-xl"}>
|
|
<p className={isDark ? "text-sm font-black text-white" : "text-sm font-black text-[#0F172A]"}>
|
|
Arraste variáveis para a imagem
|
|
</p>
|
|
<p className="mt-1 text-xs font-bold text-[#7F8CA3]">
|
|
Os cartões ficam presos ao ponto exato da imagem.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{items.map((item) => {
|
|
const variable = variablesById.get(item.variableId);
|
|
|
|
if (!variable) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<MapCard
|
|
key={item.id}
|
|
item={item}
|
|
variable={variable}
|
|
selected={item.id === selectedItemId}
|
|
dragging={item.id === draggingItemId}
|
|
onPointerDown={onCardPointerDown}
|
|
onPointerMove={onCardPointerMove}
|
|
onPointerUp={onCardPointerUp}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
<div className={isDark ? "absolute bottom-4 left-4 z-20 flex flex-col overflow-hidden rounded-[7px] border border-[#263247] bg-[#07101B]/90 shadow-xl" : "absolute bottom-4 left-4 z-20 flex flex-col overflow-hidden rounded-[7px] border border-[#D7DEE8] bg-white/90 shadow-xl"}>
|
|
<button className="grid h-9 w-9 place-items-center text-white hover:bg-white/10 min-[1450px]:h-10 min-[1450px]:w-10">
|
|
<Plus className="h-5 w-5" />
|
|
</button>
|
|
<button className="grid h-9 w-9 place-items-center border-t border-[#263247] text-white hover:bg-white/10 min-[1450px]:h-10 min-[1450px]:w-10">
|
|
<ZoomOut className="h-5 w-5" />
|
|
</button>
|
|
<button className="grid h-9 w-9 place-items-center border-t border-[#263247] text-white hover:bg-white/10 min-[1450px]:h-10 min-[1450px]:w-10">
|
|
<ZoomIn className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className={isDark ? "absolute bottom-4 right-4 z-20 rounded-[7px] bg-[#07101B]/90 px-3 py-2 text-xs font-black text-white shadow-xl" : "absolute bottom-4 right-4 z-20 rounded-[7px] bg-white/90 px-3 py-2 text-xs font-black text-[#0F172A] shadow-xl"}>
|
|
100%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapCard({
|
|
item,
|
|
variable,
|
|
selected,
|
|
dragging,
|
|
onPointerDown,
|
|
onPointerMove,
|
|
onPointerUp,
|
|
}: {
|
|
item: MapItem;
|
|
variable: SynopticVariable;
|
|
selected: boolean;
|
|
dragging: boolean;
|
|
onPointerDown: (event: PointerEvent<HTMLDivElement>, item: MapItem) => void;
|
|
onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
|
|
onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
|
|
}) {
|
|
const Icon = variable.icon;
|
|
const isLarge = item.variant === "large";
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Mover ${variable.label}`}
|
|
onPointerDown={(event) => onPointerDown(event, item)}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onPointerCancel={onPointerUp}
|
|
className="absolute z-10 cursor-grab touch-none select-none active:cursor-grabbing"
|
|
style={{
|
|
left: `${item.x}%`,
|
|
top: `${item.y}%`,
|
|
transform: "translate(-50%, -100%)",
|
|
boxShadow: dragging ? "0 24px 80px rgba(24,184,166,0.26)" : undefined,
|
|
}}
|
|
>
|
|
<div
|
|
className={
|
|
selected
|
|
? `relative mb-7 rounded-[8px] border border-[#4FD1C5] bg-[#07101B]/92 shadow-2xl ${isLarge ? "w-[210px] px-4 py-3" : "w-[150px] px-3 py-2"
|
|
}`
|
|
: `relative mb-7 rounded-[8px] border border-[#34425B] bg-[#07101B]/90 shadow-2xl ${isLarge ? "w-[210px] px-4 py-3" : "w-[150px] px-3 py-2"
|
|
}`
|
|
}
|
|
>
|
|
{selected && (
|
|
<>
|
|
{[
|
|
"-top-1 -left-1",
|
|
"-top-1 -right-1",
|
|
"-bottom-1 -left-1",
|
|
"-bottom-1 -right-1",
|
|
].map((position) => (
|
|
<span
|
|
key={position}
|
|
className={`absolute h-2 w-2 rounded-full bg-white ${position}`}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
<div className={isLarge ? "pointer-events-none space-y-3" : "pointer-events-none flex items-center gap-2"}>
|
|
<div className={isLarge ? "flex items-center gap-2" : "contents"}>
|
|
<span className="shrink-0" style={{ color: variable.color }}>
|
|
<Icon className={isLarge ? "h-8 w-8" : "h-7 w-7"} />
|
|
</span>
|
|
|
|
<div className="min-w-0">
|
|
{item.showLabel && (
|
|
<p className={isLarge ? "truncate text-xs font-bold text-[#A8B3C7]" : "truncate text-[10px] font-bold text-[#A8B3C7]"}>
|
|
{variable.label}
|
|
</p>
|
|
)}
|
|
|
|
<p className={isLarge ? "mt-1 text-2xl font-black leading-none text-white" : "mt-0.5 text-lg font-black leading-none text-white"}>
|
|
{formatVariableValue(variable)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{isLarge && (
|
|
<div className="grid grid-cols-2 gap-2 border-t border-white/10 pt-2 text-[10px] font-bold text-[#7F8CA3]">
|
|
<span>{variable.group}</span>
|
|
<span className="text-right">
|
|
{variable.connected ? "Online" : "Offline"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<span
|
|
className="pointer-events-none absolute left-1/2 top-[calc(100%-28px)] h-7 w-px -translate-x-1/2 bg-white/70"
|
|
aria-hidden
|
|
/>
|
|
|
|
<span
|
|
className="pointer-events-none absolute left-1/2 top-full h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-[#18B8A6]"
|
|
aria-hidden
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PropertiesPanel({
|
|
isDark,
|
|
selectedItem,
|
|
selectedVariable,
|
|
onRemoveSelectedItem,
|
|
onVariantChange,
|
|
onShowLabelChange,
|
|
}: {
|
|
isDark: boolean;
|
|
selectedItem: MapItem | null;
|
|
selectedVariable: SynopticVariable | null;
|
|
onRemoveSelectedItem: () => void;
|
|
onVariantChange: (variant: CardVariant) => void;
|
|
onShowLabelChange: (showLabel: boolean) => void;
|
|
}) {
|
|
if (!selectedItem || !selectedVariable) {
|
|
return (
|
|
<aside
|
|
className={
|
|
isDark
|
|
? "flex min-h-0 min-w-0 flex-col rounded-[8px] border border-[#263247] bg-[#0E1726]"
|
|
: "flex min-h-0 min-w-0 flex-col rounded-[8px] border border-[#D7DEE8] bg-white"
|
|
}
|
|
>
|
|
<div className={isDark ? "flex h-12 shrink-0 items-center justify-between border-b border-[#263247]/70 px-3 min-[1450px]:px-4" : "flex h-12 shrink-0 items-center justify-between border-b border-[#D7DEE8] px-3 min-[1450px]:px-4"}>
|
|
<h2 className="text-base font-black">Propriedades</h2>
|
|
<X className="h-4 w-4 text-[#7F8CA3]" />
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-3 min-[1450px]:p-4">
|
|
<div className={isDark ? "rounded-[7px] border border-[#263247] bg-[#07101B] p-4 text-center" : "rounded-[7px] border border-[#D7DEE8] bg-[#F8FAFC] p-4 text-center"}>
|
|
<p className="text-sm font-black">Nenhum item selecionado</p>
|
|
<p className="mt-1 text-xs font-bold text-[#7F8CA3]">
|
|
Arraste uma variável para a imagem.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
const Icon = selectedVariable.icon;
|
|
|
|
return (
|
|
<aside
|
|
className={
|
|
isDark
|
|
? "flex min-h-0 min-w-0 flex-col rounded-[8px] border border-[#263247] bg-[#0E1726]"
|
|
: "flex min-h-0 min-w-0 flex-col rounded-[8px] border border-[#D7DEE8] bg-white"
|
|
}
|
|
>
|
|
<div className={isDark ? "flex h-12 shrink-0 items-center justify-between border-b border-[#263247]/70 px-3 min-[1450px]:px-4" : "flex h-12 shrink-0 items-center justify-between border-b border-[#D7DEE8] px-3 min-[1450px]:px-4"}>
|
|
<h2 className="text-base font-black">Propriedades</h2>
|
|
<X className="h-4 w-4 text-[#7F8CA3]" />
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-3 min-[1450px]:p-4">
|
|
<div className="mb-5 flex items-center gap-3">
|
|
<div
|
|
className="grid h-10 w-10 place-items-center rounded-[6px] bg-[#0EA5E9]/15"
|
|
style={{ color: selectedVariable.color }}
|
|
>
|
|
<Icon className="h-5 w-5" />
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-bold text-[#7F8CA3]">Item selecionado</p>
|
|
<p className="truncate text-sm font-black">{selectedVariable.label}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<PanelSection title="Posição">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<InputMock label="X" value={`${selectedItem.x.toFixed(1)} %`} isDark={isDark} />
|
|
<InputMock label="Y" value={`${selectedItem.y.toFixed(1)} %`} isDark={isDark} />
|
|
</div>
|
|
</PanelSection>
|
|
|
|
<PanelSection title="Dados">
|
|
<InputMock label="Grupo" value={selectedVariable.group} isDark={isDark} />
|
|
<InputMock label="Valor" value={formatVariableValue(selectedVariable)} isDark={isDark} />
|
|
</PanelSection>
|
|
|
|
<PanelSection title="Cartão">
|
|
<SegmentedControl
|
|
isDark={isDark}
|
|
value={selectedItem.variant}
|
|
options={[
|
|
{ label: "Compacto", value: "compact" },
|
|
{ label: "Grande", value: "large" },
|
|
]}
|
|
onChange={(value) => onVariantChange(value as CardVariant)}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => onShowLabelChange(!selectedItem.showLabel)}
|
|
className={
|
|
selectedItem.showLabel
|
|
? "flex h-10 w-full items-center justify-between rounded-[6px] border border-[#18B8A6] bg-[#18B8A6]/10 px-3 text-sm font-black text-[#18B8A6]"
|
|
: isDark
|
|
? "flex h-10 w-full items-center justify-between rounded-[6px] border border-[#263247] bg-[#07101B] px-3 text-sm font-bold text-[#A8B3C7]"
|
|
: "flex h-10 w-full items-center justify-between rounded-[6px] border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm font-bold text-slate-600"
|
|
}
|
|
>
|
|
Mostrar nome
|
|
<span>{selectedItem.showLabel ? "Ligado" : "Desligado"}</span>
|
|
</button>
|
|
</PanelSection>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onRemoveSelectedItem}
|
|
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[6px] border border-red-500/70 text-sm font-black text-red-400 transition hover:bg-red-500 hover:text-white"
|
|
>
|
|
Remover do mapa
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function PanelSection({
|
|
title,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="mb-6 space-y-3">
|
|
<h3 className="text-sm font-black">{title}</h3>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function InputMock({
|
|
label,
|
|
value,
|
|
isDark,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
isDark: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={
|
|
isDark
|
|
? "flex h-10 items-center justify-between rounded-[6px] border border-[#263247] bg-[#07101B] px-3"
|
|
: "flex h-10 items-center justify-between rounded-[6px] border border-[#D7DEE8] bg-[#F8FAFC] px-3"
|
|
}
|
|
>
|
|
<span className="text-xs font-bold text-[#7F8CA3]">{label}</span>
|
|
<span className="text-sm font-black">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function clampPercent(value: number) {
|
|
return Math.min(96, Math.max(4, value));
|
|
}
|
|
|
|
function SegmentedControl({
|
|
isDark,
|
|
value,
|
|
options,
|
|
onChange,
|
|
}: {
|
|
isDark: boolean;
|
|
value: string;
|
|
options: { label: string; value: string }[];
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
return (
|
|
<div className={isDark ? "grid grid-cols-2 rounded-[6px] border border-[#263247] bg-[#07101B] p-1" : "grid grid-cols-2 rounded-[6px] border border-[#D7DEE8] bg-[#F8FAFC] p-1"}>
|
|
{options.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => onChange(option.value)}
|
|
className={
|
|
value === option.value
|
|
? "h-8 rounded-[5px] bg-[#18B8A6] text-xs font-black text-white"
|
|
: isDark
|
|
? "h-8 rounded-[5px] text-xs font-bold text-[#7F8CA3] transition hover:text-white"
|
|
: "h-8 rounded-[5px] text-xs font-bold text-slate-500 transition hover:text-[#0F172A]"
|
|
}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function smallToolClass(isDark: boolean) {
|
|
return isDark
|
|
? "grid h-9 w-9 place-items-center rounded-[6px] text-[#A8B3C7] transition hover:bg-white/10 hover:text-white"
|
|
: "grid h-9 w-9 place-items-center rounded-[6px] text-slate-600 transition hover:bg-[#F1F5F9] hover:text-[#0F172A]";
|
|
}
|
|
|
|
function formatVariableValue(variable: SynopticVariable) {
|
|
if (variable.value === null || variable.value === undefined) {
|
|
return "—";
|
|
}
|
|
|
|
if (typeof variable.value === "number") {
|
|
const value = Number.isInteger(variable.value)
|
|
? variable.value.toString()
|
|
: variable.value.toFixed(1);
|
|
|
|
return `${value} ${variable.unit}`.trim();
|
|
}
|
|
|
|
return `${variable.value} ${variable.unit}`.trim();
|
|
}
|
|
export default SynopticPage; |