Files
litoral-central-frontend/src/features/synoptic/pages/SynopticPage.tsx
T
2026-06-01 12:23:37 +01:00

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;