Neuroredes
Módulos do Produto

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.

Capítulo 01

Stack

Quatro bibliotecas fazem o trabalho pesado. Nenhuma é abstraída por wrapper nosso — o código toca direto nas APIs delas.

Biblioteca Função
graphologyEstrutura de dados do grafo (nós, arestas, atributos). Independente de renderização.
sigmaRenderizador WebGL/Canvas. Eventos, reducers programáveis, controles.
@react-sigma/coreWrapper React: <SigmaContainer>, hooks useSigma(), useRegisterEvents().
graphology-layout-forceatlas2 + ...-noverlapLayouts físicos. ForceAtlas2 posiciona, NoVerlap descoloca.
@sigma/export-imageExport PNG do canvas atual.
Capítulo 02

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.

  1. Layout server-side chama fetchGraphDataById(surveyId) (com cache() para deduplicar) e injeta em <GraphProvider>.
  2. Service faz POST na Lambda ${NEXT_PUBLIC_GRAPH_PROCESSOR_URL}graph com { surveyId }. Falha silenciosa devolvendo null (página mostra estado de erro).
  3. GraphContext normaliza, calcula o mapa de display IDs (1..N) e expõe estado via useGraph().
  4. GraphPageClient monta o SigmaContainer, popula o Graphology em useEffects reativos a filtros, e desenha.
services/graph.service.ts typescript
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;
  }
}
Capítulo 03

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 carrega synchrony (0–6), answerST e answerTS (0–3).
legend
{ node_size, edge_thickness, depts_style, synchrony_style[] }. synchrony_style mapeia 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 key e text. Define o dropdown principal.
survey_name / company_name
Usados no nome do arquivo PNG exportado.
Capítulo 04

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).

EstadoPara que serve
graphRawPayload bruto da Lambda. Source of truth, imutável.
graphLegendCópia mutável da legenda — usuário pode ajustar tamanho de nó/aresta no modal.
selectedQuestionKeyPergunta corrente (define qual lista de edges renderizar).
selectedAttributesArray de arrays — valores ativos por dimensão de atributo. Filtra nós.
colorAttributeIndexQual atributo (0–5) define a cor dos nós.
nodeAttributeColorsCustomizações de cor por valor de atributo. Override do hash padrão.
selectedSynchroniesClasses de conexão visíveis. Filtra arestas.
selectedNodeKeyNó selecionado — abre painel direito com detalhes.
displayIdByOriginalIdMapa original.id → 1..N. Ver Cap. 05.
Capítulo 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.

contexts/GraphContext.tsx typescript
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]);
Nota

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.

Capítulo 06

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.
Capítulo 07

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.

FNV-1a hash → índice de cor typescript
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;
}
Por que FNV-1a

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.

Capítulo 08

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.

GraphPageClient — após adicionar arestas typescript
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.

Atenção

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.

Capítulo 09

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.

Capítulo 10

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
enterNodeMostra NodeTooltip fixo com ID + label + atributos.
leaveNodeEsconde tooltip. Limpa highlight.
enterEdgeMostra EdgeTooltip com tipo de sincronia + respostas (Nulo/Baixo/Médio/Alto).
downNode + mousemovebody + mouseupDrag manual — usuário arrasta nó pra reposicionar.
clickNodesetSelectedNodeKey(node) → abre painel direito com detalhe estatístico.
clickStagesetSelectedNodeKey(null) → fecha painel.

Highlight em três modos

Os reducers de nó e aresta inspecionam o estado atual e reescrevem propriedades on-the-fly:

  1. Nó selecionado: mostra só ele, vizinhos e arestas entre eles. O resto fica hidden.
  2. Hover em nó: mesmo comportamento do anterior, mas temporário enquanto o mouse está sobre.
  3. Hover em aresta: source e target ganham zIndex: 1; todo o resto fica em cinza (#d3d3d3) sem label, e arestas não-foco ficam hidden.

Como tudo é feito em reducer (não mutação), tirar o mouse restaura o estado anterior sem custo. Sigma só re-renderiza.

Capítulo 11

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.
Capítulo 12

Exportar PNG

Botão de download no ControlsContainer aciona downloadAsPNG() do @sigma/export-image. Filename inclui timestamp, nome da empresa e pergunta atual.

GraphPageClient — handleExport typescript
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.

Capítulo 13

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.

Capítulo 14

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.
Capítulo 15

Quirks & armadilhas

respondentStats indexado pelo ID original
O resto da UI mostra display ID (1..N) — mas pra puxar stats você precisa do respondent.id antes 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 mousemove global
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.