Files
ESPDocs/components/ConnectionsDiagram.jsx
Lucas RayanandClaude Sonnet 5.5 7d0edf13c4
CI / Lint, design e build (push) Successful in 1m52s
CI / Trivy (dependências e segredos) (push) Successful in 12s
CI / Imagem Docker (push) Successful in 2m26s
feat: site search with a dialog in the header and a /api/busca endpoint
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 08:02:57 -03:00

430 lines
18 KiB
React

"use client";
import { useState, useEffect, useMemo, useRef, Suspense } from "react";
import { createPortal } from "react-dom";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { AlertTriangle, CheckCircle, List, Grid, Search, Sliders, X, Zap } from "lucide-react";
/*
* As cores abaixo são dado de visualização: identificam o tipo de função do
* pino no diagrama. O texto nunca fica sobre elas; elas aparecem só como borda
* ou ponto, e o rótulo usa as cores do tema (contraste AAA).
*/
const FILTER_CATEGORIES = [
{ id: "All", name: "Todos", color: "#8b5cf6" }, // purple
{ id: "GND", name: "Terra (GND)", color: "#64748b" }, // slate
{ id: "Power", name: "Alimentação", color: "#ef4444" }, // red
{ id: "GPIO", name: "GPIO", color: "#22c55e" }, // green
{ id: "Low Power", name: "GPIO LP", color: "#f59e0b" }, // amber
{ id: "ADC", name: "ADC", color: "#ec4899" }, // pink
{ id: "DAC", name: "DAC", color: "#a855f7" }, // purple
{ id: "Touch", name: "Touch", color: "#06b6d4" }, // cyan
{ id: "UART", name: "UART", color: "#3b82f6" }, // blue
{ id: "SPI", name: "SPI", color: "#f43f5e" }, // rose
{ id: "USB", name: "USB", color: "#a855f7" }, // purple
{ id: "Clock", name: "Clock", color: "#eab308" }, // yellow
{ id: "JTAG", name: "JTAG", color: "#6366f1" }, // indigo
{ id: "Strapping", name: "Inicialização", color: "#dc2626" }, // red
{ id: "DSI", name: "Display (DSI)", color: "#f97316" }, // orange
{ id: "CSI", name: "Câmera (CSI)", color: "#14b8a6" }, // teal
{ id: "Ethernet", name: "Ethernet", color: "#0284c7" }, // sky
{ id: "SD", name: "Cartão SD", color: "#10b981" }, // emerald
{ id: "I2C", name: "I2C", color: "#f59e0b" }, // amber
{ id: "Antena", name: "Antena", color: "#06b6d4" }, // cyan
];
const type_colors = {
power: "#ef4444", // high-voltage red
ground: "#64748b", // slate gray
io: "#22c55e", // laser green
analog: "#ec4899", // dynamic pink
dedicated: "#3b82f6", // electric blue
nc: "#94a3b8", // muted gray
};
/**
* Aceita ?pinos=28,30,31&motivo=... (gerado pela busca do site): mostra só
* esses pinos, pelo número físico, com um aviso para voltar à lista completa.
* O Suspense é exigido por useSearchParams em páginas estáticas.
*/
export default function ConnectionsDiagram(props) {
return (
<Suspense fallback={null}>
<ConnectionsDiagramInner {...props} />
</Suspense>
);
}
function ConnectionsDiagramInner({ connections, serie }) {
const params = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const pinosFiltro = useMemo(() => new Set((params.get("pinos") ?? "").split(",").filter(Boolean)), [params]);
const motivoFiltro = params.get("motivo") ?? "";
const [activeFilter, setActiveFilter] = useState("All");
const [selectedConnection, setSelectedConnection] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState("grid");
const [mounted, setMounted] = useState(false);
const dialogRef = useRef(null);
const gatilhoRef = useRef(null);
useEffect(() => {
setMounted(true);
}, []);
const isConnectionVisible = (connection) => {
const matchesFilter = activeFilter === "All" || connection.categorias.includes(activeFilter);
const matchesSearch =
searchQuery === "" ||
connection.nome.toLowerCase().includes(searchQuery.toLowerCase()) ||
connection.numero.toString().toLowerCase().includes(searchQuery.toLowerCase());
const matchesPinos = pinosFiltro.size === 0 || pinosFiltro.has(String(connection.numero));
return matchesFilter && matchesSearch && matchesPinos;
};
const getCategoryColor = (category) => {
const filter = FILTER_CATEGORIES.find((f) => f.id === category);
return filter?.color || "#64748b";
};
const nomeCategoria = (cat) => (cat === "Strapping" ? "Inicialização" : cat);
const visibleConnections = useMemo(() => connections.filter(isConnectionVisible), [connections, activeFilter, searchQuery, pinosFiltro]);
const mostrarTodos = () => router.replace(`${pathname}#connections`, { scroll: false });
const abrir = (connection, evento) => {
gatilhoRef.current = evento.currentTarget;
setSelectedConnection(connection);
};
const fechar = () => {
setSelectedConnection(null);
gatilhoRef.current?.focus?.();
};
// Diálogo: Esc fecha, o foco entra nele, fica preso enquanto aberto e volta ao pino de origem.
useEffect(() => {
if (!selectedConnection) return;
const dialogo = dialogRef.current;
const focaveis = () => dialogo?.querySelectorAll('button, [href], input, [tabindex]:not([tabindex="-1"])') ?? [];
focaveis()[0]?.focus();
const aoTeclar = (e) => {
if (e.key === "Escape") {
fechar();
return;
}
if (e.key !== "Tab") return;
const itens = [...focaveis()];
if (itens.length === 0) return;
const primeiro = itens[0];
const ultimo = itens[itens.length - 1];
if (e.shiftKey && document.activeElement === primeiro) {
e.preventDefault();
ultimo.focus();
} else if (!e.shiftKey && document.activeElement === ultimo) {
e.preventDefault();
primeiro.focus();
}
};
document.addEventListener("keydown", aoTeclar);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", aoTeclar);
document.body.style.overflow = "";
};
}, [selectedConnection]);
if (!connections || connections.length === 0) {
return (
<div className="px-4 py-14 text-center">
<Sliders className="mx-auto h-10 w-10 text-muted" aria-hidden="true" />
<p className="mt-4 font-display text-xl font-semibold text-ink">Diagrama de conexões indisponível</p>
<p className="mt-1 text-sm text-muted">Este modelo ainda não tem um mapa detalhado de conexões e barramentos.</p>
</div>
);
}
const numeroCurto = (c) => (typeof c.numero === "number" ? c.numero : String(c.numero).substring(0, 4));
return (
<div className="space-y-6">
<div>
<h2 className="font-display text-xl font-semibold tracking-[-0.03em] text-ink">Diagrama de conexões interativo</h2>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-muted">
Todas as conexões e a multiplexação do {serie.nome_completo}. Busque por nome ou número, filtre por função e abra um pino para ver os detalhes.
</p>
</div>
{pinosFiltro.size > 0 && (
<div role="status" className="callout flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-ink">
<strong className="font-semibold">Filtrado pela busca:</strong> {motivoFiltro || "pinos selecionados"} ({pinosFiltro.size}{" "}
{pinosFiltro.size === 1 ? "pino" : "pinos"})
</p>
<button type="button" onClick={mostrarTodos} className="btn btn-ghost btn-sm">
Mostrar todos os pinos
</button>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1">
<label htmlFor="busca-pino" className="sr-only">Buscar conexão por nome ou número</label>
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted" aria-hidden="true" />
<input
id="busca-pino"
type="search"
placeholder="Buscar pino (ex.: GPIO1, TXD, XTAL)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoComplete="off"
className="field pl-11 pr-4 text-sm"
/>
</div>
<div role="group" aria-label="Modo de exibição" className="flex gap-2">
<button type="button" onClick={() => setViewMode("grid")} aria-pressed={viewMode === "grid"} className="chip-btn">
<Grid className="h-4 w-4" aria-hidden="true" />
Grade
</button>
<button type="button" onClick={() => setViewMode("list")} aria-pressed={viewMode === "list"} className="chip-btn">
<List className="h-4 w-4" aria-hidden="true" />
Lista
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-ink">Função do pino</h3>
<p role="status" aria-live="polite" className="text-sm text-muted">
{visibleConnections.length} de {connections.length} pinos
</p>
</div>
<div role="group" aria-label="Filtrar por função" className="mt-3 flex flex-wrap gap-2">
{FILTER_CATEGORIES.map((filter) => {
const isActive = activeFilter === filter.id;
const count = connections.filter((c) => (filter.id === "All" ? true : c.categorias.includes(filter.id))).length;
if (count === 0 && !isActive) return null; // filtros que não se aplicam a este chip
return (
<button key={filter.id} type="button" onClick={() => setActiveFilter(filter.id)} aria-pressed={isActive} className="chip-btn">
{filter.id !== "All" && <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: filter.color }} aria-hidden="true" />}
{filter.name}
<span className="tabular-nums opacity-80">{count}</span>
</button>
);
})}
</div>
</div>
{visibleConnections.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line-strong px-6 py-12 text-center">
<Search className="mx-auto h-8 w-8 text-muted" aria-hidden="true" />
<p className="mt-3 font-semibold text-ink">Nenhuma conexão encontrada</p>
<p className="mt-1 text-sm text-muted">Ajuste a busca ou volte para todas as funções.</p>
<button type="button" onClick={() => { setActiveFilter("All"); setSearchQuery(""); if (pinosFiltro.size) mostrarTodos(); }} className="btn btn-primary btn-sm mt-5">
Limpar busca e filtros
</button>
</div>
) : viewMode === "grid" ? (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{visibleConnections.map((connection) => {
const baseColor = type_colors[connection.tipo] || "#94a3b8";
return (
<li key={`${connection.numero}-${connection.nome}`} className="relative">
<button
type="button"
onClick={(e) => abrir(connection, e)}
aria-haspopup="dialog"
aria-label={`Pino ${connection.nome}, número ${connection.numero}${connection.avisos ? ", com restrições de uso" : ""}`}
className="flex w-full flex-col items-center rounded-xl border border-line bg-surface p-3 transition-colors hover:bg-surface-2"
>
<span
className="grid h-11 w-11 place-items-center rounded-full border-2 bg-surface text-sm font-semibold tabular-nums text-ink"
style={{ borderColor: baseColor }}
>
{numeroCurto(connection)}
</span>
<span className="mt-2 break-all text-center text-sm font-medium leading-tight text-ink">{connection.nome}</span>
<span className="mt-2 flex flex-wrap justify-center gap-1" aria-hidden="true">
{connection.categorias.slice(0, 5).map((cat) => (
<span key={cat} className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: getCategoryColor(cat) }} />
))}
</span>
</button>
{connection.avisos && (
<span className="pointer-events-none absolute -right-1.5 -top-1.5 grid h-6 w-6 place-items-center rounded-full border-2 border-surface bg-danger-soft text-danger" aria-hidden="true">
<AlertTriangle className="h-3 w-3" />
</span>
)}
</li>
);
})}
</ul>
) : (
<div className="overflow-x-auto rounded-xl border border-line">
<table className="w-full min-w-[40rem] border-collapse text-left text-sm">
<caption className="sr-only">Conexões do {serie.nome_completo}</caption>
<thead>
<tr className="border-b border-line bg-surface-2 text-muted">
<th scope="col" className="px-4 py-3 font-medium">Pino</th>
<th scope="col" className="px-4 py-3 font-medium">Nome</th>
<th scope="col" className="px-4 py-3 font-medium">Funções</th>
<th scope="col" className="px-4 py-3 font-medium">Uso</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{visibleConnections.map((connection) => {
const baseColor = type_colors[connection.tipo] || "#94a3b8";
return (
<tr key={`${connection.numero}-${connection.nome}`} className="hover:bg-surface-2/60">
<td className="px-4 py-2.5">
<button
type="button"
onClick={(e) => abrir(connection, e)}
aria-haspopup="dialog"
aria-label={`Abrir detalhes do pino ${connection.nome}, número ${connection.numero}`}
className="grid h-11 w-11 place-items-center rounded-full border-2 bg-surface text-sm font-semibold tabular-nums text-ink hover:bg-surface-2"
style={{ borderColor: baseColor }}
>
{numeroCurto(connection)}
</button>
</td>
<td className="px-4 py-2.5 font-medium text-ink">{connection.nome}</td>
<td className="max-w-md px-4 py-2.5 text-muted">
<span className="line-clamp-2">{connection.funcoes.join(", ")}</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5">
{connection.avisos ? (
<span className="inline-flex items-center gap-1.5 font-medium text-danger">
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
Restrito
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-ok">
<CheckCircle className="h-4 w-4" aria-hidden="true" />
Livre
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{connections.some((c) => c.avisos) && (
<div className="callout callout-warn flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-gold" aria-hidden="true" />
<div>
<p className="font-semibold text-ink">Notas sobre a pinagem</p>
<ul className="mt-1 list-disc space-y-1 pl-5">
<li>Pinos com o ícone de alerta têm papel especial no boot ou limitações analógicas. Abra o pino para ver os detalhes.</li>
<li>Isole os circuitos de toque capacitivo de pinos com ruído de RF acoplado.</li>
</ul>
</div>
</div>
)}
{selectedConnection && mounted && createPortal(
<div className="dialog-scrim fixed inset-0 z-50 flex items-center justify-center bg-code/80 p-4 backdrop-blur-sm" onClick={fechar}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="dialog-panel flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-line bg-surface shadow-[var(--shadow)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-line p-5 sm:p-7">
<div className="flex min-w-0 items-center gap-4">
<span
className="grid h-14 w-14 shrink-0 place-items-center rounded-full border-2 bg-surface text-lg font-semibold tabular-nums text-ink"
style={{ borderColor: type_colors[selectedConnection.tipo] || "#64748b" }}
>
{numeroCurto(selectedConnection)}
</span>
<div className="min-w-0">
<h3 id="modal-title" className="break-words font-display text-2xl font-semibold tracking-[-0.03em] text-ink">
{selectedConnection.nome}
</h3>
<p className="text-sm text-muted">Pino físico {selectedConnection.numero}</p>
</div>
</div>
<button type="button" onClick={fechar} aria-label="Fechar detalhes do pino" className="grid h-11 w-11 shrink-0 place-items-center rounded-full text-muted transition-colors hover:bg-surface-2 hover:text-ink">
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div className="flex-1 space-y-6 overflow-y-auto p-5 sm:p-7">
{selectedConnection.categorias.length > 0 && (
<section>
<h4 className="text-sm font-semibold text-ink">Categorias</h4>
<ul className="mt-3 flex flex-wrap gap-2">
{selectedConnection.categorias.map((cat) => (
<li key={cat} className="chip">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: getCategoryColor(cat) }} aria-hidden="true" />
{nomeCategoria(cat)}
</li>
))}
</ul>
</section>
)}
{selectedConnection.alimentacao && (
<section>
<h4 className="flex items-center gap-2 text-sm font-semibold text-ink">
<Zap className="h-4 w-4 text-danger" aria-hidden="true" />
Domínio de alimentação
</h4>
<ul className="tile mt-3 space-y-2">
{selectedConnection.alimentacao.map((source) => (
<li key={source} className="text-sm text-ink-soft">{source}</li>
))}
</ul>
</section>
)}
<section>
<h4 className="text-sm font-semibold text-ink">Funções disponíveis</h4>
<ul className="tile mt-3 grid gap-2.5 sm:grid-cols-2">
{selectedConnection.funcoes.map((func) => (
<li key={func} className="flex items-center gap-2.5 text-sm text-ink-soft">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: type_colors[selectedConnection.tipo] || "#64748b" }} aria-hidden="true" />
{func}
</li>
))}
</ul>
</section>
{selectedConnection.avisos && (
<section className="callout callout-danger">
<h4 className="flex items-center gap-2 font-semibold text-ink">
<AlertTriangle className="h-4 w-4 text-danger" aria-hidden="true" />
Avisos de engenharia
</h4>
<ul className="mt-2 list-disc space-y-1.5 pl-5">
{selectedConnection.avisos.map((aviso) => (
<li key={aviso}>{aviso}</li>
))}
</ul>
</section>
)}
</div>
<div className="border-t border-line p-4 sm:px-7">
<button type="button" onClick={fechar} className="btn btn-primary w-full">Fechar</button>
</div>
</div>
</div>,
document.body
)}
</div>
);
}