Gráfico
Visualização interativa da rede de sincronia entre respondentes — feita em
Sigma.js + Graphology, alimentada pela Lambda graph-processor.
Filtros por pergunta, atributo e tipo de conexão; layout físico via ForceAtlas2;
seleção, hover, exportação PNG e customização de paleta.
Stack
Quatro bibliotecas fazem o trabalho pesado. Nenhuma é abstraída por wrapper nosso — o código toca direto nas APIs delas.
| Biblioteca | Função |
|---|---|
graphology | Estrutura de dados do grafo (nós, arestas, atributos). Independente de renderização. |
sigma | Renderizador WebGL/Canvas. Eventos, reducers programáveis, controles. |
@react-sigma/core | Wrapper React: <SigmaContainer>, hooks useSigma(), useRegisterEvents(). |
graphology-layout-forceatlas2 + ...-noverlap | Layouts físicos. ForceAtlas2 posiciona, NoVerlap descoloca. |
@sigma/export-image | Export PNG do canvas atual. |
Fluxo de dados — server, context, render
A página de gráfico está em app/(main-noheader)/surveys/[id]/graph.
Segue o padrão geral do projeto: Server Component faz fetch, Context provê,
Client Component renderiza.
-
Layout server-side chama
fetchGraphDataById(surveyId)(comcache()para deduplicar) e injeta em<GraphProvider>. -
Service faz
POSTna Lambda${NEXT_PUBLIC_GRAPH_PROCESSOR_URL}graphcom{ surveyId }. Falha silenciosa devolvendonull(página mostra estado de erro). -
GraphContext normaliza, calcula o mapa de display IDs
(1..N) e expõe estado via
useGraph(). -
GraphPageClient monta o
SigmaContainer, popula oGraphologyemuseEffects reativos a filtros, e desenha.
export async function fetchGraphDataById(surveyId: number): Promise<SurveyGraph | null> {
if (!GRAPH_PROCESSOR_URL) return null;
try {
const response = await fetch(`${GRAPH_PROCESSOR_URL}graph`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ surveyId }),
});
if (!response.ok) return null;
return (await response.json()) as SurveyGraph | null;
} catch (err) {
console.log('Graph Lambda fetch error:', err);
return null;
}
}
Forma do payload
Tipo central SurveyGraph em types/graph.type.ts.
Mantenha o tipo sincronizado com o output do Neurocalc (caps. 10 do
neurocalc) — divergência aqui é uma das fontes
mais comuns de bug silencioso no grafo.
- nodes
- Array de
{ key, id, label, attributes[] }.attributesé array indexado (0..5) com os valores de até 6 atributos. - edges
Record<questionKey, Edge[]>. Cada pergunta tem sua própria lista de arestas. Cada aresta carregasynchrony(0–6),answerSTeanswerTS(0–3).- legend
{ node_size, edge_thickness, depts_style, synchrony_style[] }.synchrony_stylemapeia cada uma das 7 classes em label + cor — é o que vira a paleta padrão e a legenda lateral.- respondentStats
Record<respondentId, Record<questionKey, RespondentStatDetail>>. Painel direito lê daqui pra mostrar breakdown do respondente selecionado.- attributes
- Metadados dos 6 atributos:
{ key, label, values[] }. Usado pelos filtros e pela seleção de "cor por atributo". - questions
- Array das 5 perguntas com
keyetext. Define o dropdown principal. - survey_name / company_name
- Usados no nome do arquivo PNG exportado.
GraphContext — estado central
Onze pedaços de estado, todos derivados (ou inicializados) do payload bruto.
Sempre que graphData muda, tudo é resetado para o estado padrão
(todas as perguntas, todos os atributos, todas as sincronias visíveis).
| Estado | Para que serve |
|---|---|
graphRaw | Payload bruto da Lambda. Source of truth, imutável. |
graphLegend | Cópia mutável da legenda — usuário pode ajustar tamanho de nó/aresta no modal. |
selectedQuestionKey | Pergunta corrente (define qual lista de edges renderizar). |
selectedAttributes | Array de arrays — valores ativos por dimensão de atributo. Filtra nós. |
colorAttributeIndex | Qual atributo (0–5) define a cor dos nós. |
nodeAttributeColors | Customizações de cor por valor de atributo. Override do hash padrão. |
selectedSynchronies | Classes de conexão visíveis. Filtra arestas. |
selectedNodeKey | Nó selecionado — abre painel direito com detalhes. |
displayIdByOriginalId | Mapa original.id → 1..N. Ver Cap. 05. |
Remap de IDs — display 1..N
Os IDs reais de respondente vêm grandes e fora de sequência
(4287, 4288, 4291, ...). Para um grafo legível na tela, mapeamos
para 1..N ordenado. Commit 21961ff.
const displayIdByOriginalId = useMemo<Record<number, number>>(() => {
if (!graphData) return {};
const sortedIds = graphData.nodes.map((n) => n.id).sort((a, b) => a - b);
const map: Record<number, number> = {};
sortedIds.forEach((originalId, index) => {
map[originalId] = index + 1;
});
return map;
}, [graphData]);
Todo lugar que mostra ID na UI lê via displayIdByOriginalId[id] ?? id
— fallback defensivo se o mapa estiver incompleto. respondentStats,
em contraste, ainda é indexado pelo ID original. Cuidado ao cruzar dados.
Carregando o Graphology — clusters + jitter
O Graphology é populado dentro de useEffects no
GraphPageClient. Não há um único "load" — são três efeitos
independentes, cada um reativo a um subconjunto de dependências, que juntos
materializam o grafo final.
| Efeito | Dispara quando muda | O que faz |
|---|---|---|
| Carregar nós | graphRaw, colorAttributeIndex, displayIdByOriginalId |
Limpa o grafo, adiciona todos os nós com posição inicial. |
| Filtrar nós | selectedAttributes |
Ativa/desativa hidden em cada nó. |
| Colorir nós | colorAttributeIndex, nodeAttributeColors |
Atualiza color via hash determinístico ou override. |
| Recriar arestas | selectedQuestionKey, selectedSynchronies, graphLegend |
Limpa arestas, adiciona apenas as da pergunta corrente, roda layout. |
Posicionamento inicial — clusters em órbita
Antes do ForceAtlas2 puxar tudo pelas forças, os nós já começam agrupados pelo valor do atributo selecionado para coloração. Isso ajuda o solver a convergir mais rápido e cria a sensação de "comunidades visíveis".
- ORBIT_RADIUS = 1000 — distância do centro de cada cluster ao centro do grafo.
- JITTER_RADIUS = 150 — raio do espalhamento aleatório dentro do cluster.
- Cada cluster recebe um ângulo de órbita determinístico, então a mesma pesquisa renderiza no mesmo lugar entre sessões.
Coloração — hash determinístico + override
A cor de um nó depende do valor que ele tem no atributo escolhido como
referência (colorAttributeIndex). Se o usuário customizou aquele
valor no modal de configuração, vence o override. Caso contrário, hash
determinístico em uma paleta categórica de 60 cores.
function getHashedIndexForArray(value: string | number | undefined, arrayLength: number): number {
if (!value) return 0;
let hash = 2166136261;
const str = String(value);
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) % arrayLength;
}
Determinístico (mesmo valor → mesma cor entre sessões e entre máquinas),
rápido e distribui bem sobre uma paleta pequena. Não use Math.random()
— quebra a propriedade de "Engenharia está sempre azul" que o usuário aprende.
Customização no ConfigModal
O usuário pode abrir o modal e atribuir cor exata a um valor específico de
atributo (ex.: forçar "Diretoria" para vermelho). A escolha vai pra
nodeAttributeColors[attrIndex][valueKey] no context. O useEffect
de coloração reage. Resetar volta tudo ao hash.
Layout físico — ForceAtlas2 + NoVerlap
ForceAtlas2 resolve forças até convergir (atração por arestas, repulsão entre nós, gravidade ao centro). NoVerlap roda depois pra descolar nós que ficaram sobrepostos.
if (addedEdgeCount > 0) {
forceAtlas2.assign(graph, {
iterations: 150,
settings: {
gravity: 0.001,
scalingRatio: 100,
edgeWeightInfluence: 1,
barnesHutOptimize: true,
linLogMode: true,
adjustSizes: false,
},
});
noverlap.assign(graph, {
maxIterations: 50,
settings: { ratio: 2.0, margin: 5 },
});
}
Pesos das arestas afetam coesão
Cada aresta carrega um weight usado pelo ForceAtlas2 como força de
atração. O código atribui 0.002 quando os dois lados pertencem ao
mesmo cluster (mesmo valor do atributo de cor) e 0.005 quando são
diferentes. Resultado: clusters se mantêm visualmente coesos.
barnesHutOptimize: true + linLogMode: true é a
combinação que funciona bem para pesquisas de 50–500 nós. Acima de ~800 nós
o tempo de layout sobe rápido (segundos). Reduzir iterations ou
desligar barnesHutOptimize são as duas alavancas mais fáceis.
Filtros e seleção
Pergunta (1 de 5)
Dropdown no GraphLeftPanel. Trocar a pergunta dispara o efeito de
recriação de arestas e re-roda o layout. Não recria nós — eles
ficam no mesmo lugar, só as conexões mudam.
Atributos (6 dimensões)
GraphFiltersPanel mostra grupos colapsáveis, um por dimensão. Cada
valor tem checkbox + swatch da cor (com a cor do hash/override). Desmarcar um
valor: nós daquele grupo ficam hidden: true no Graphology.
Arestas ligadas a nó oculto também são contadas como "não exibindo" no
contador do painel direito.
Sincronia (7 classes)
SynchronyFilterPanel separa em "Síncrono" e "Assíncrono". Cada
classe é uma das saídas do Neurocalc (high_synchrony,
mid_synchrony, ..., high_null_asynchrony). Desmarcar
uma classe esconde todas as arestas dessa categoria.
Busca por nome ou ID
Input com autocomplete. Compara por substring case-insensitive contra
label e contra o display ID. Clicar na sugestão equivale a clicar
no nó no canvas — abre o painel direito.
Interações — eventos & reducers
Sigma expõe dois superpoderes que esse módulo usa pesado: eventos (callbacks de mouse) e reducers (funções que reescrevem as propriedades de nó/aresta no momento do render, sem mutar o Graphology).
| Evento Sigma | Efeito |
|---|---|
enterNode | Mostra NodeTooltip fixo com ID + label + atributos. |
leaveNode | Esconde tooltip. Limpa highlight. |
enterEdge | Mostra EdgeTooltip com tipo de sincronia + respostas (Nulo/Baixo/Médio/Alto). |
downNode + mousemovebody + mouseup | Drag manual — usuário arrasta nó pra reposicionar. |
clickNode | setSelectedNodeKey(node) → abre painel direito com detalhe estatístico. |
clickStage | setSelectedNodeKey(null) → fecha painel. |
Highlight em três modos
Os reducers de nó e aresta inspecionam o estado atual e reescrevem propriedades on-the-fly:
- Nó selecionado: mostra só ele, vizinhos e arestas entre eles. O resto fica
hidden. - Hover em nó: mesmo comportamento do anterior, mas temporário enquanto o mouse está sobre.
- Hover em aresta: source e target ganham
zIndex: 1; todo o resto fica em cinza (#d3d3d3) sem label, e arestas não-foco ficamhidden.
Como tudo é feito em reducer (não mutação), tirar o mouse restaura o estado anterior sem custo. Sigma só re-renderiza.
Painéis & UI
| Componente | Conteúdo |
|---|---|
GraphLeftPanel |
Dropdown de pergunta, dropdown de "colorir por atributo", busca por nó, filtros de atributo, filtros de sincronia. Colapsável. |
GraphRightPanel |
Stats da pesquisa (totais + exibindo) e, se houver nó selecionado, breakdown completo (sincronias, assincronias, nulos, in/out-degree). |
ConfigModal |
Sliders de tamanho de nó (2–40) e espessura de aresta (0–10), customização de cor por valor de atributo, customização das cores de sincronia, botão "Resetar tudo". |
ControlsContainer (bottom-right) |
Zoom in/out, fullscreen, exportar PNG, abrir ConfigModal. |
NodeTooltip / EdgeTooltip |
Tooltips flutuantes (position: fixed) seguindo o mouse com offset de +15, +15. |
Exportar PNG
Botão de download no ControlsContainer aciona downloadAsPNG()
do @sigma/export-image. Filename inclui timestamp, nome da empresa
e pergunta atual.
const companyName =
graphRaw?.survey_name ||
graphRaw?.company ||
graphRaw?.company_name ||
'Pesquisa';
const qIndex = graphRaw?.questions.findIndex((x) => x.key === selectedQuestionKey);
const perguntaSuffix = qIndex >= 0 ? ` - Pergunta ${qIndex + 1}` : '';
const rawFileName = `[${timestamp}] ${companyName}${perguntaSuffix}`;
const safeFileName = rawFileName.replace(/[\\/:*?"<>|]/g, '-');
downloadAsPNG(sigma, { fileName: safeFileName, backgroundColor: '#ffffff' });
Fundo branco fixo no PNG — o canvas no app é transparente sobre fundo claro, mas o export força branco pra compartilhar fora do app sem artefatos.
Performance & limites
Threshold de aresta
Arestas com synchrony < 1 ou > 6 são silenciosamente
descartadas — não entram no Graphology. Filtra ruído (respostas inválidas ou
nulas dos dois lados) e reduz drasticamente o número de arestas a renderizar.
Contagem de visíveis (visibleItems)
Um useEffect reativo aos filtros itera o grafo dentro de um
requestAnimationFrame e conta nós/arestas visíveis. Resultado vai
para o painel direito ("Exibindo X de Y"). É a única operação O(N+E) por
mudança de filtro — escala bem até ~5k arestas.
Reducers em vez de mutação
Highlights e modos de hover usam reducers que reescrevem propriedades no momento
do render. O Graphology nunca é mutado para refletir hover.
Significa que mouseleave não precisa fazer "undo" — basta o reducer
parar de reescrever.
labelRenderedSizeThreshold
Sigma só renderiza labels de nós com tamanho efetivo ≥ 6 pixels.
Em zoom-out, labels somem; em zoom-in, voltam. Sem isso, ler um cluster cheio
de 100 labels sobrepostos é impraticável.
Estados de erro & vazio
| Situação | O que aparece |
|---|---|
GRAPH_PROCESSOR_URL ausente |
Service devolve null → página mostra mensagem de erro genérica. |
Lambda devolve null ou 5xx |
Mesma tela — "Erro interno. Não foi possível puxar os dados da pesquisa". |
| Pesquisa sem respondentes | Lambda devolve estrutura mínima vazia. Front mostra canvas vazio mas funcional. |
| Pergunta sem nenhuma aresta válida | Layout não roda. Nós ficam nas posições iniciais (clusters em órbita). |
| Loading inicial | loading.tsx com texto "Carregando…" — sem skeleton. |
Quirks & armadilhas
- respondentStats indexado pelo ID original
- O resto da UI mostra display ID (1..N) — mas pra puxar stats você precisa do
respondent.idantes do remap. Use o nó do Graphology para recuperar. - Mudar de pergunta não realinha nós
- Só arestas são recriadas. Posições persistem entre perguntas — intencional, dá noção de continuidade.
- Layout só roda se houver aresta
if (addedEdgeCount > 0). Pergunta sem conexão visível deixa nós nas posições iniciais (em cluster), sem nenhuma força aplicada.- Customização de cor por atributo é por valor exato
- Se um respondente tem atributo com valor diferente do customizado (até por uma letra), volta pra cor do hash. Útil pra entender por que alguém ficou de cor "errada".
- Tooltip fixo posicionado por
mousemoveglobal - Não é via Sigma — é um listener no
document. Se você criar overlay novo em cima do canvas, ele intercepta antes do Sigma — atenção ao z-index. - Atributo "data" no cluster
- Strings em formato de data como valor de atributo bagunçam o cluster (criam grupo por dia). O backend filtra esses casos no Excel, mas o gráfico mostra. Se ver um cluster estranho, confere se o atributo virou data por engano.