Laboratório ao vivo "grafo-conhecimento" do curso Frontends com Vibecoding (capítulo bônus). Código-fonte completo, copiado de src/components/course/blocks/live-labs/ por scripts/export-fvc-labs-txt.mjs. Licença de uso: a mesma do repositório do site. Cada arquivo abaixo começa com uma linha '==== nome ===='. ==== GrafoConhecimentoLab.tsx ==== "use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import Graph from "graphology"; import Sigma from "sigma"; import forceAtlas2 from "graphology-layout-forceatlas2"; import FA2Layout from "graphology-layout-forceatlas2/worker"; import clusters from "graphology-generators/random/clusters"; import grafoReal from "@/data/frontends-knowledge-graph.generated.json"; import type { LiveLabMeta, LiveLabProps } from "./registry"; import styles from "./GrafoConhecimento.module.css"; /** * Laboratório "grafo-conhecimento": um grafo de conhecimento amplo animado com fluidez. * * Painel 1: sigma.js 3 desenha o grafo em WebGL e o ForceAtlas2 (graphology) calcula o * layout num Web Worker criado a partir de um Blob, então a thread da página segue livre * para desenhar a 60 quadros por segundo enquanto os aglomerados se formam. Dois conjuntos * de dados: o grafo REAL do curso (trilhas, capítulos, recursos, categorias, laboratórios * e bibliotecas, materializado em src/data/frontends-knowledge-graph.generated.json) e um * sintético de 1.000 a 20.000 nós gerado no navegador (graphology-generators, rotulado * "cenário ilustrativo"). * * Painel 2: cosmos.gl calcula a força e desenha na GPU (WebGL 2), o que leva a cena a * 50 mil, 100 mil ou 200 mil pontos; o motor só desce quando a pessoa aperta o botão. * * Regras da casa: cor só por token do tema, resolvida para hex pela sonda de um pixel; * nada anda sozinho com movimento reduzido; tudo para quando a aba se esconde ou o palco * sai da tela; zoom pela roda do mouse desligado por padrão, para não roubar a rolagem. */ type Rgb = [number, number, number]; interface NoGrafo { label: string; tipo: string; x: number; y: number; size: number; color: string; grau: number; href?: string; } interface ArestaGrafo { size: number; color: string; relacao?: string; } type GrafoTipado = Graph; interface DadosReais { nodes: { id: string; label: string; type: string; href?: string }[]; edges: { source: string; target: string; type: string }[]; } const DADOS_REAIS = grafoReal as unknown as DadosReais; /* Tokens do tema com hex de reserva (valores "era #..." do globals.css). */ const TOKENS: { chave: string; token: string; reserva: string }[] = [ { chave: "acento", token: "--course-accent", reserva: "#0070ca" }, { chave: "sucesso", token: "--success", reserva: "#2e844a" }, { chave: "alerta", token: "--warning", reserva: "#e58a00" }, { chave: "perigo", token: "--danger", reserva: "#ba0517" }, { chave: "apoio", token: "--text-muted", reserva: "#5c5c5c" }, { chave: "texto", token: "--text", reserva: "#181818" }, { chave: "fundo", token: "--bg-white", reserva: "#ffffff" }, { chave: "borda", token: "--border", reserva: "#c9c9c9" }, ]; interface Paleta { cores: Record; } /** Lê um token no próprio elemento e o resolve para RGB pintando um pixel numa sonda. */ function resolverToken(elemento: HTMLElement, ctxSonda: CanvasRenderingContext2D, token: string, reserva: string): Rgb { const estilo = getComputedStyle(elemento); let valor = estilo.getPropertyValue(token).trim(); if (!valor && token === "--course-accent") valor = estilo.getPropertyValue("--accent").trim(); ctxSonda.fillStyle = "#010203"; if (valor) ctxSonda.fillStyle = valor; if (ctxSonda.fillStyle === "#010203") ctxSonda.fillStyle = reserva; ctxSonda.clearRect(0, 0, 1, 1); ctxSonda.fillRect(0, 0, 1, 1); const d = ctxSonda.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2]]; } function montarPaleta(elemento: HTMLElement): Paleta { const sonda = document.createElement("canvas"); sonda.width = 1; sonda.height = 1; const ctx = sonda.getContext("2d", { willReadFrequently: true }); const cores: Record = {}; for (const t of TOKENS) { cores[t.chave] = ctx ? resolverToken(elemento, ctx, t.token, t.reserva) : hexParaRgb(t.reserva); } return { cores }; } function hexParaRgb(hex: string): Rgb { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } const rgbCss = (c: Rgb) => `rgb(${c[0]} ${c[1]} ${c[2]})`; const misturar = (a: Rgb, b: Rgb, t: number): Rgb => [ Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t), Math.round(a[2] + (b[2] - a[2]) * t), ]; /* Tipo de nó -> chave de cor. O grafo real usa os seis tipos do curso; o sintético, cinco tipos de entidade de um grafo de conhecimento de empresa (cenário ilustrativo). */ const COR_POR_TIPO: Record = { trilha: "perigo", capitulo: "acento", recurso: "sucesso", categoria: "alerta", laboratorio: "texto", biblioteca: "apoio", Pessoa: "acento", Empresa: "sucesso", Produto: "alerta", Local: "perigo", Conceito: "apoio", }; const NOME_DO_TIPO: Record = { trilha: "trilha", capitulo: "capítulo", recurso: "recurso", categoria: "categoria", laboratorio: "laboratório", biblioteca: "biblioteca", Pessoa: "pessoa", Empresa: "empresa", Produto: "produto", Local: "local", Conceito: "conceito", }; const TIPOS_SINTETICOS = ["Pessoa", "Empresa", "Produto", "Local", "Conceito"] as const; const TAMANHOS_SINTETICOS = [1000, 5000, 10000, 20000] as const; const TAMANHOS_MASSIVOS = [50000, 100000, 200000] as const; /** Gerador determinístico: a mesma semente produz o mesmo grafo em toda visita. */ function criarRng(semente: number): () => number { let s = semente >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } const fmtInteiro = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 0 }); const fmtDecimal = new Intl.NumberFormat("pt-BR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); /* ---------- Construção dos grafos ---------- */ function grafoDoCurso(paleta: Paleta): GrafoTipado { const g: GrafoTipado = new Graph({ type: "undirected", multi: false }); const n = DADOS_REAIS.nodes.length; DADOS_REAIS.nodes.forEach((no, i) => { const ang = (i / n) * Math.PI * 2; g.addNode(no.id, { label: encurtarRotulo(no.label), tipo: no.type, x: Math.cos(ang) * 100, y: Math.sin(ang) * 100, size: 3, color: rgbCss(paleta.cores[COR_POR_TIPO[no.type] ?? "apoio"]), grau: 0, href: no.href, }); }); for (const a of DADOS_REAIS.edges) { if (!g.hasNode(a.source) || !g.hasNode(a.target) || a.source === a.target) continue; g.mergeEdge(a.source, a.target, { size: 1, color: rgbCss(misturar(paleta.cores.borda, paleta.cores.fundo, 0.2)), relacao: a.type }); } ajustarTamanhos(g, 2.5, 14); return g; } function grafoSintetico(ordem: number, paleta: Paleta): GrafoTipado { const rng = criarRng(20260925 + ordem); const base = clusters(Graph, { order: ordem, size: ordem * 2, clusters: 12, clusterDensity: 0.82, rng }); const g: GrafoTipado = new Graph({ type: "undirected", multi: false }); const raio = 40 + Math.sqrt(ordem) * 1.2; base.forEachNode((chave, attrs) => { const indice = Number(chave); const cluster = Number((attrs as { cluster?: number }).cluster ?? 0); const tipo = TIPOS_SINTETICOS[cluster % TIPOS_SINTETICOS.length]; const ang = rng() * Math.PI * 2; const r = Math.sqrt(rng()) * raio; g.addNode(chave, { label: `${NOME_DO_TIPO[tipo]} ${fmtInteiro.format(indice + 1)}`, tipo, x: Math.cos(ang) * r, y: Math.sin(ang) * r, size: 2, color: rgbCss(paleta.cores[COR_POR_TIPO[tipo]]), grau: 0, }); }); const corAresta = rgbCss(misturar(paleta.cores.borda, paleta.cores.fundo, 0.35)); base.forEachEdge((_e, _attrs, origem, destino) => { if (origem === destino) return; g.mergeEdge(origem, destino, { size: 0.6, color: corAresta }); }); ajustarTamanhos(g, 1.5, ordem > 5000 ? 8 : 10); return g; } /** Tamanho pelo grau: raiz quadrada, com piso e teto. */ function ajustarTamanhos(g: GrafoTipado, piso: number, teto: number) { let maior = 1; g.forEachNode((n) => { maior = Math.max(maior, g.degree(n)); }); g.updateEachNodeAttributes((n, attrs) => { const grau = g.degree(n); return { ...attrs, grau, size: piso + (teto - piso) * Math.sqrt(grau / maior) }; }); } function repintar(g: GrafoTipado, paleta: Paleta, sintetico: boolean) { const corAresta = rgbCss(misturar(paleta.cores.borda, paleta.cores.fundo, sintetico ? 0.35 : 0.2)); g.updateEachNodeAttributes((_n, attrs) => ({ ...attrs, color: rgbCss(paleta.cores[COR_POR_TIPO[attrs.tipo] ?? "apoio"]) })); g.updateEachEdgeAttributes((_e, attrs) => ({ ...attrs, color: corAresta })); } interface Ficha { label: string; tipo: string; grau: number; href?: string; } interface LinhaReserva { label: string; tipo: string; grau: number; } function topoPorGrau(g: GrafoTipado, quantos: number): LinhaReserva[] { const linhas: LinhaReserva[] = []; g.forEachNode((_n, attrs) => { linhas.push({ label: attrs.label, tipo: NOME_DO_TIPO[attrs.tipo] ?? attrs.tipo, grau: attrs.grau }); }); return linhas.sort((a, b) => b.grau - a.grau).slice(0, quantos); } /** Rótulo curto para o palco estreito: o título inteiro fica na ficha do nó e na tabela de reserva. */ function encurtarRotulo(texto: string, maximo = 26): string { if (texto.length <= maximo) return texto; const corte = texto.slice(0, maximo); const espaco = corte.lastIndexOf(" "); return `${(espaco > 12 ? corte.slice(0, espaco) : corte).trimEnd()}…`; } /* ---------- Painel 2: dados para a GPU ---------- */ interface DadosMassivos { posicoes: Float32Array; ligacoes: Float32Array; cores: Float32Array; tamanhos: Float32Array; pontos: number; arestas: number; } function gerarMassivo(pontos: number, paleta: Paleta): DadosMassivos { const rng = criarRng(777 + pontos); const grupos = 18; const lado = 4096; const centros: [number, number][] = Array.from({ length: grupos }, () => [lado * (0.15 + rng() * 0.7), lado * (0.15 + rng() * 0.7)]); const posicoes = new Float32Array(pontos * 2); const cores = new Float32Array(pontos * 4); const tamanhos = new Float32Array(pontos); const grupoDoPonto = new Uint8Array(pontos); const tons = TIPOS_SINTETICOS.map((t) => paleta.cores[COR_POR_TIPO[t]]); for (let i = 0; i < pontos; i++) { const grupo = (rng() * grupos) | 0; grupoDoPonto[i] = grupo; const espalha = lado * 0.04; posicoes[i * 2] = centros[grupo][0] + (rng() + rng() - 1) * espalha; posicoes[i * 2 + 1] = centros[grupo][1] + (rng() + rng() - 1) * espalha; const tom = tons[grupo % tons.length]; cores[i * 4] = tom[0] / 255; cores[i * 4 + 1] = tom[1] / 255; cores[i * 4 + 2] = tom[2] / 255; cores[i * 4 + 3] = 0.9; tamanhos[i] = 1.6 + rng() * 1.8; } /* Uma ligação e meia por ponto, quase sempre dentro do próprio grupo: é o que forma os aglomerados. */ const totalLigacoes = Math.floor(pontos * 1.5); const ligacoes = new Float32Array(totalLigacoes * 2); const membros: number[][] = Array.from({ length: grupos }, () => []); for (let i = 0; i < pontos; i++) membros[grupoDoPonto[i]].push(i); for (let k = 0; k < totalLigacoes; k++) { const origem = (rng() * pontos) | 0; const mesmoGrupo = rng() < 0.9; const lista = mesmoGrupo ? membros[grupoDoPonto[origem]] : null; const destino = lista && lista.length > 1 ? lista[(rng() * lista.length) | 0] : (rng() * pontos) | 0; ligacoes[k * 2] = origem; ligacoes[k * 2 + 1] = destino === origem ? (destino + 1) % pontos : destino; } return { posicoes, ligacoes, cores, tamanhos, pontos, arestas: totalLigacoes }; } /** Só o que o laboratório usa do cosmos.gl, para o `import()` tardio ficar tipado. */ interface GrafoCosmos { setPointPositions(p: Float32Array): void; setPointColors(c: Float32Array): void; setPointSizes(s: Float32Array): void; setLinks(l: Float32Array): void; render(alpha?: number): void; start(alpha?: number): void; pause(): void; unpause(): void; fitView(duration?: number): void; setConfigPartial(config: Record): void; destroy(): void; readonly isSimulationRunning: boolean; } type ConstrutorCosmos = new (div: HTMLDivElement, config?: Record) => GrafoCosmos; /* ---------- O componente ---------- */ export default function GrafoConhecimentoLab(_props: LiveLabProps) { const uid = useId(); const raizRef = useRef(null); const palcoRef = useRef(null); const grafoRef = useRef(null); const sigmaRef = useRef | null>(null); const layoutRef = useRef | null>(null); const paletaRef = useRef(null); const destaqueRef = useRef(null); const vizinhosRef = useRef>(new Set()); const iteracoesRef = useRef(0); const quadrosRef = useRef(0); const visivelRef = useRef(true); const naTelaRef = useRef(true); const [movimentoReduzido, setMovimentoReduzido] = useState(false); const [sintetico, setSintetico] = useState(false); const [ordemSintetica, setOrdemSintetica] = useState(TAMANHOS_SINTETICOS[1]); const [layoutRodando, setLayoutRodando] = useState(false); const [zoomPelaRoda, setZoomPelaRoda] = useState(false); const [ficha, setFicha] = useState(null); const [contagem, setContagem] = useState({ nos: 0, arestas: 0 }); const [quadrosPorSegundo, setQuadrosPorSegundo] = useState(0); const [iteracoesPorSegundo, setIteracoesPorSegundo] = useState(0); const [reserva, setReserva] = useState([]); const [busca, setBusca] = useState(""); const [avisoBusca, setAvisoBusca] = useState(null); const [semWebgl, setSemWebgl] = useState(false); /* Movimento reduzido: nada anda sozinho. */ useEffect(() => { const consulta = window.matchMedia("(prefers-reduced-motion: reduce)"); const aplicar = () => setMovimentoReduzido(consulta.matches); aplicar(); consulta.addEventListener("change", aplicar); return () => consulta.removeEventListener("change", aplicar); }, []); const pararLayout = useCallback(() => { layoutRef.current?.stop(); setLayoutRodando(false); }, []); const aplicarReducers = useCallback((renderer: Sigma, paleta: Paleta) => { const esmaecido = rgbCss(misturar(paleta.cores.borda, paleta.cores.fundo, 0.55)); /* No sigma 3 o objeto devolvido pelo reducer É o dado de exibição: sem espalhar `data`, x e y somem e o renderizador lança "could not find a valid position". */ renderer.setSetting("nodeReducer", (no, data) => { const foco = destaqueRef.current; if (!foco) return { ...data }; if (no === foco) return { ...data, highlighted: true, zIndex: 2, forceLabel: true }; if (vizinhosRef.current.has(no)) return { ...data, zIndex: 1, forceLabel: true }; return { ...data, color: esmaecido, label: null, zIndex: 0 }; }); renderer.setSetting("edgeReducer", (aresta, data) => { const foco = destaqueRef.current; if (!foco) return { ...data }; const g = renderer.getGraph(); const [a, b] = g.extremities(aresta); if (a === foco || b === foco) return { ...data, color: rgbCss(paleta.cores.acento), size: Math.max(1.2, data.size * 2), zIndex: 1 }; return { ...data, hidden: true }; }); }, []); /** Monta grafo, sigma e layout para o conjunto escolhido; refaz do zero a cada troca. */ const montar = useCallback(() => { const palco = palcoRef.current; const raiz = raizRef.current; if (!palco || !raiz) return; layoutRef.current?.kill(); sigmaRef.current?.kill(); layoutRef.current = null; sigmaRef.current = null; setLayoutRodando(false); setFicha(null); destaqueRef.current = null; vizinhosRef.current = new Set(); const paleta = montarPaleta(raiz); paletaRef.current = paleta; const g = sintetico ? grafoSintetico(ordemSintetica, paleta) : grafoDoCurso(paleta); grafoRef.current = g; setContagem({ nos: g.order, arestas: g.size }); setReserva(topoPorGrau(g, 10)); const fonte = getComputedStyle(raiz).fontFamily || "system-ui, sans-serif"; let renderer: Sigma; try { renderer = new Sigma(g, palco, { renderLabels: true, labelFont: fonte, labelSize: 13, labelWeight: "600", labelColor: { color: rgbCss(paleta.cores.texto) }, labelRenderedSizeThreshold: g.order > 5000 ? 9 : g.order > 1000 ? 6 : 5, labelDensity: g.order > 5000 ? 0.04 : 0.1, hideEdgesOnMove: g.order > 5000, hideLabelsOnMove: g.order > 5000, defaultEdgeColor: rgbCss(misturar(paleta.cores.borda, paleta.cores.fundo, 0.2)), enableCameraRotation: false, enableCameraZooming: zoomPelaRoda, minCameraRatio: 0.02, maxCameraRatio: 3, stagePadding: 24, zIndex: true, }); } catch { setSemWebgl(true); return; } sigmaRef.current = renderer; aplicarReducers(renderer, paleta); renderer.on("enterNode", ({ node }) => { destaqueRef.current = node; vizinhosRef.current = new Set(g.neighbors(node)); renderer.refresh({ skipIndexation: true }); }); renderer.on("leaveNode", () => { destaqueRef.current = null; vizinhosRef.current = new Set(); renderer.refresh({ skipIndexation: true }); }); renderer.on("clickNode", ({ node }) => { const attrs = g.getNodeAttributes(node); setFicha({ label: attrs.label, tipo: NOME_DO_TIPO[attrs.tipo] ?? attrs.tipo, grau: attrs.grau, href: attrs.href }); }); renderer.on("clickStage", () => setFicha(null)); const settings = { ...forceAtlas2.inferSettings(g), barnesHutOptimize: g.order > 2000, slowDown: g.order > 5000 ? 8 : 5 }; layoutRef.current = new FA2Layout(g, { settings }); g.on("eachNodeAttributesUpdated", () => { iteracoesRef.current += 1; }); }, [sintetico, ordemSintetica, zoomPelaRoda, aplicarReducers]); useEffect(() => { montar(); return () => { layoutRef.current?.kill(); sigmaRef.current?.kill(); layoutRef.current = null; sigmaRef.current = null; }; }, [montar]); /* Cores acompanham o tema: data-theme no html e a preferência do sistema. */ useEffect(() => { const raiz = raizRef.current; if (!raiz) return; const recolorir = () => { const g = grafoRef.current; const renderer = sigmaRef.current; if (!g || !renderer) return; const paleta = montarPaleta(raiz); paletaRef.current = paleta; repintar(g, paleta, sintetico); renderer.setSettings({ labelColor: { color: rgbCss(paleta.cores.texto) }, defaultEdgeColor: rgbCss(misturar(paleta.cores.borda, paleta.cores.fundo, 0.2)), }); aplicarReducers(renderer, paleta); renderer.refresh(); }; const observador = new MutationObserver(recolorir); observador.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class", "style"] }); const consulta = window.matchMedia("(prefers-color-scheme: dark)"); consulta.addEventListener("change", recolorir); return () => { observador.disconnect(); consulta.removeEventListener("change", recolorir); }; }, [sintetico, aplicarReducers]); /* Medidor: quadros por segundo e iterações de layout por segundo, em janelas de 1 s. */ useEffect(() => { let raf = 0; let marco = performance.now(); const passo = (agora: number) => { quadrosRef.current += 1; if (agora - marco >= 1000) { setQuadrosPorSegundo(Math.round((quadrosRef.current * 1000) / (agora - marco))); setIteracoesPorSegundo(Math.round((iteracoesRef.current * 1000) / (agora - marco))); quadrosRef.current = 0; iteracoesRef.current = 0; marco = agora; } raf = window.requestAnimationFrame(passo); }; raf = window.requestAnimationFrame(passo); return () => window.cancelAnimationFrame(raf); }, []); /* Tudo para quando a aba se esconde ou o palco sai da tela. */ useEffect(() => { const palco = palcoRef.current; if (!palco) return; const avaliar = () => { if ((!visivelRef.current || !naTelaRef.current) && layoutRef.current?.isRunning()) pararLayout(); }; const aoVisibilidade = () => { visivelRef.current = document.visibilityState === "visible"; avaliar(); }; document.addEventListener("visibilitychange", aoVisibilidade); const io = new IntersectionObserver((entradas) => { naTelaRef.current = entradas.some((e) => e.isIntersecting); avaliar(); }); io.observe(palco); return () => { document.removeEventListener("visibilitychange", aoVisibilidade); io.disconnect(); }; }, [pararLayout]); const rodarLayout = useCallback(() => { const layout = layoutRef.current; if (!layout) return; layout.start(); setLayoutRodando(true); if (movimentoReduzido) { window.setTimeout(() => { layout.stop(); setLayoutRodando(false); }, 3000); } }, [movimentoReduzido]); const focarNo = useCallback( (chave: string) => { const renderer = sigmaRef.current; const g = grafoRef.current; if (!renderer || !g) return; const dados = renderer.getNodeDisplayData(chave); if (!dados) return; destaqueRef.current = chave; vizinhosRef.current = new Set(g.neighbors(chave)); const attrs = g.getNodeAttributes(chave); setFicha({ label: attrs.label, tipo: NOME_DO_TIPO[attrs.tipo] ?? attrs.tipo, grau: attrs.grau, href: attrs.href }); renderer.refresh({ skipIndexation: true }); void renderer.getCamera().animate({ x: dados.x, y: dados.y, ratio: 0.25 }, { duration: movimentoReduzido ? 0 : 500 }); }, [movimentoReduzido], ); const buscar = useCallback(() => { const g = grafoRef.current; if (!g) return; const termo = busca.trim().toLocaleLowerCase("pt-BR"); if (!termo) return; let achado: string | null = null; g.forEachNode((n, attrs) => { if (!achado && attrs.label.toLocaleLowerCase("pt-BR").includes(termo)) achado = n; }); if (achado) { setAvisoBusca(null); focarNo(achado); } else { setAvisoBusca(`Nenhum nó com "${busca.trim()}" neste conjunto.`); } }, [busca, focarNo]); const verTudo = useCallback(() => { destaqueRef.current = null; vizinhosRef.current = new Set(); setFicha(null); const renderer = sigmaRef.current; if (!renderer) return; renderer.refresh({ skipIndexation: true }); void renderer.getCamera().animatedReset({ duration: movimentoReduzido ? 0 : 500 }); }, [movimentoReduzido]); const zoom = useCallback( (sinal: 1 | -1) => { const camera = sigmaRef.current?.getCamera(); if (!camera) return; const opcoes = { duration: movimentoReduzido ? 0 : 250 }; void (sinal > 0 ? camera.animatedZoom(opcoes) : camera.animatedUnzoom(opcoes)); }, [movimentoReduzido], ); const sugestoes = useMemo(() => { const g = grafoRef.current; if (!g) return [] as string[]; if (!sintetico) { const lista: string[] = []; g.forEachNode((_n, attrs) => { lista.push(attrs.label); }); return lista.sort((a, b) => a.localeCompare(b, "pt-BR")).slice(0, 300); } return reserva.map((l) => l.label); // reserva muda a cada montagem, então as sugestões acompanham o conjunto ativo }, [sintetico, reserva]); /* ---------- Painel 2 (cosmos.gl) ---------- */ const palcoGpuRef = useRef(null); const cosmosRef = useRef(null); const construtorRef = useRef(null); const inicioSimRef = useRef(null); const [estagioGpu, setEstagioGpu] = useState<"inicial" | "carregando" | "pronto" | "sem-webgl2" | "erro">("inicial"); const [pontosGpu, setPontosGpu] = useState(TAMANHOS_MASSIVOS[0]); const [contagemGpu, setContagemGpu] = useState({ pontos: 0, arestas: 0 }); const [simulando, setSimulando] = useState(false); const [tempoEstabilizar, setTempoEstabilizar] = useState(null); const [zoomGpu, setZoomGpu] = useState(false); const configCosmos = useCallback( (paleta: Paleta, zoomAtivo: boolean, reduzido: boolean): Record => ({ backgroundColor: rgbCss(paleta.cores.fundo), spaceSize: 8192, pointSizeScale: 1, linkWidthScale: 0.6, linkColor: rgbCss(misturar(paleta.cores.borda, paleta.cores.fundo, 0.3)), linkColorInterpolateFromEndpoints: false, hoveredPointRingColor: rgbCss(paleta.cores.acento), enableSimulation: true, simulationGravity: 0.35, simulationCenter: 0.2, simulationRepulsion: 0.9, simulationRepulsionTheta: 1.2, simulationLinkSpring: 1.2, simulationLinkDistance: 4, simulationFriction: 0.85, simulationDecay: reduzido ? 2500 : 4000, enableZoom: zoomAtivo, enableDrag: false, fitViewOnInit: true, fitViewDelay: 0, pixelRatio: Math.min(2, window.devicePixelRatio || 1), scalePointsOnZoom: true, renderLinks: true, onSimulationEnd: () => { setSimulando(false); if (inicioSimRef.current !== null) setTempoEstabilizar((performance.now() - inicioSimRef.current) / 1000); }, }), [], ); const montarGpu = useCallback( (Construtor: ConstrutorCosmos, pontos: number) => { const palco = palcoGpuRef.current; const raiz = raizRef.current; if (!palco || !raiz) return; cosmosRef.current?.destroy(); cosmosRef.current = null; palco.replaceChildren(); const paleta = paletaRef.current ?? montarPaleta(raiz); const dados = gerarMassivo(pontos, paleta); const grafo = new Construtor(palco, configCosmos(paleta, zoomGpu, movimentoReduzido)); grafo.setPointPositions(dados.posicoes); grafo.setPointColors(dados.cores); grafo.setPointSizes(dados.tamanhos); grafo.setLinks(dados.ligacoes); grafo.render(); grafo.pause(); cosmosRef.current = grafo; setContagemGpu({ pontos: dados.pontos, arestas: dados.arestas }); setSimulando(false); setTempoEstabilizar(null); }, [configCosmos, zoomGpu, movimentoReduzido], ); const carregarGpu = useCallback(async () => { const teste = document.createElement("canvas"); if (!teste.getContext("webgl2")) { setEstagioGpu("sem-webgl2"); return; } setEstagioGpu("carregando"); try { const modulo = await import("@cosmos.gl/graph"); construtorRef.current = modulo.Graph as unknown as ConstrutorCosmos; setEstagioGpu("pronto"); } catch { setEstagioGpu("erro"); } }, []); useEffect(() => { if (estagioGpu !== "pronto" || !construtorRef.current) return; montarGpu(construtorRef.current, pontosGpu); }, [estagioGpu, pontosGpu, montarGpu]); useEffect(() => { return () => { cosmosRef.current?.destroy(); cosmosRef.current = null; }; }, []); /* A simulação da GPU também para com a aba escondida ou o palco fora da tela. */ useEffect(() => { const palco = palcoGpuRef.current; if (!palco) return; const pausar = () => { const grafo = cosmosRef.current; if (grafo?.isSimulationRunning) { grafo.pause(); setSimulando(false); } }; const aoVisibilidade = () => { if (document.visibilityState !== "visible") pausar(); }; document.addEventListener("visibilitychange", aoVisibilidade); const io = new IntersectionObserver((entradas) => { if (!entradas.some((e) => e.isIntersecting)) pausar(); }); io.observe(palco); return () => { document.removeEventListener("visibilitychange", aoVisibilidade); io.disconnect(); }; }, []); const simularGpu = useCallback(() => { const grafo = cosmosRef.current; if (!grafo) return; inicioSimRef.current = performance.now(); setTempoEstabilizar(null); grafo.start(1); setSimulando(true); }, []); const pausarGpu = useCallback(() => { cosmosRef.current?.pause(); setSimulando(false); }, []); const reiniciarGpu = useCallback(() => { if (construtorRef.current) montarGpu(construtorRef.current, pontosGpu); }, [montarGpu, pontosGpu]); const legenda = useMemo(() => { const paleta = paletaRef.current; const tipos = sintetico ? [...TIPOS_SINTETICOS] : ["trilha", "capitulo", "recurso", "categoria", "laboratorio", "biblioteca"]; return tipos.map((t) => ({ nome: NOME_DO_TIPO[t] ?? t, cor: paleta ? rgbCss(paleta.cores[COR_POR_TIPO[t] ?? "apoio"]) : "var(--text-muted)", })); // contagem muda a cada montagem, o que garante a paleta já resolvida // eslint-disable-next-line react-hooks/exhaustive-deps }, [sintetico, contagem]); const estadoLayout = layoutRodando ? "layout rodando no worker" : "layout parado"; return (

{movimentoReduzido ? "Movimento reduzido ativo no sistema: o layout só roda por três segundos ao apertar o botão, a câmera não anima e a simulação da GPU só anda por botão." : "Movimento completo ativo. Com movimento reduzido no sistema, nada corre sozinho e cada rodada de layout dura três segundos."}

{/* ---------- Painel 1 ---------- */}

{"1. O grafo de conhecimento com rótulos"}

{"O sigma.js desenha em WebGL; o ForceAtlas2 calcula as forças num Web Worker. Rode o layout e veja os aglomerados se formarem sem o quadro cair."}

{"Conjunto:"}
{sintetico ? (
{"Nós:"} {TAMANHOS_SINTETICOS.map((n) => ( ))}
) : null}
{layoutRodando ? ( ) : ( )}
{ e.preventDefault(); buscar(); }} > { setBusca(e.target.value); setAvisoBusca(null); }} placeholder={sintetico ? "por exemplo: empresa 12" : "por exemplo: React Flow"} autoComplete="off" /> {sugestoes.map((s) => (
{avisoBusca ?

{avisoBusca}

: null}
{semWebgl ? (

{"Este navegador não oferece WebGL; a tabela abaixo lista as entidades mais ligadas do conjunto."}

) : null}
    {legenda.map((item) => (
  • ))}
{ficha ? (

{ficha.label} {` · ${ficha.tipo} · ${fmtInteiro.format(ficha.grau)} ${ficha.grau === 1 ? "ligação" : "ligações"}`} {ficha.href ? ( <> {" · "} {ficha.href.startsWith("/") ? "abrir no curso" : "abrir a fonte"} ) : null}

) : null}

{`${fmtInteiro.format(contagem.nos)} nós e ${fmtInteiro.format(contagem.arestas)} arestas. ${fmtInteiro.format(quadrosPorSegundo)} quadros por segundo na página; ${fmtInteiro.format(iteracoesPorSegundo)} iterações de layout por segundo no worker. Estado: ${estadoLayout}.`}

{reserva.map((l) => ( ))}
{"As dez entidades mais ligadas deste conjunto (leitura para quem não vê o grafo)"}
{"Entidade"} {"Tipo"} {"Ligações"}
{l.label} {l.tipo} {fmtInteiro.format(l.grau)}

{sintetico ? "Cenário ilustrativo: nós, tipos e ligações gerados no navegador com semente fixa; os aglomerados vêm do gerador, e as cores marcam o tipo de entidade." : "Grafo real: trilhas, capítulos, recursos, categorias, laboratórios e bibliotecas deste curso, ligados pelas relações que existem no código do site."}

{/* ---------- Painel 2 ---------- */}

{"2. Massivo na GPU"}

{"O cosmos.gl calcula a força e desenha na placa de vídeo, com WebGL 2. É o que leva a cena a centenas de milhares de pontos onde a CPU pararia em vinte mil."}

{estagioGpu === "inicial" || estagioGpu === "carregando" ? (
) : null} {estagioGpu === "sem-webgl2" ? (

{"Este navegador não oferece WebGL 2; o modo massivo fica indisponível e o painel 1 continua funcionando."}

) : null} {estagioGpu === "erro" ?

{"O motor da GPU não pôde ser carregado neste navegador."}

: null} {estagioGpu === "pronto" ? ( <>
{"Pontos:"} {TAMANHOS_MASSIVOS.map((n) => ( ))}
{simulando ? ( ) : ( )}
) : null}

{estagioGpu === "pronto" ? `${fmtInteiro.format(contagemGpu.pontos)} pontos e ${fmtInteiro.format(contagemGpu.arestas)} ligações. ${fmtInteiro.format(quadrosPorSegundo)} quadros por segundo na página. ${ simulando ? "Simulação em andamento na GPU." : tempoEstabilizar !== null ? `Estabilizou em ${fmtDecimal.format(tempoEstabilizar)} s.` : "Simulação parada." }` : "Nada desceu ainda: o peso do motor só chega depois do seu pedido."}

{"Cenário ilustrativo: pontos e ligações gerados no navegador em 18 grupos, com semente fixa. Toda conta de força acontece nos shaders da placa de vídeo."}

); } export const meta: LiveLabMeta = { biblioteca: "sigma.js, graphology com ForceAtlas2 e cosmos.gl", pacote: "sigma, graphology, graphology-layout-forceatlas2, graphology-generators, @cosmos.gl/graph", versao: "3.0.3, 0.26.0, 0.10.1, 0.11.2 e 3.4.2", docsUrl: "https://www.sigmajs.org/docs/", npmUrl: "https://www.npmjs.com/package/sigma", trechoLinguagem: "ts", trecho: `import Graph from "graphology"; import Sigma from "sigma"; import forceAtlas2 from "graphology-layout-forceatlas2"; import FA2Layout from "graphology-layout-forceatlas2/worker"; // o layout roda fora da thread da página const grafo = new Graph({ type: "undirected" }); for (const no of dados.nodes) grafo.addNode(no.id, { label: no.label, x: Math.random(), y: Math.random(), size: 3, color }); for (const a of dados.edges) grafo.mergeEdge(a.source, a.target, { size: 1, color: corAresta }); const renderer = new Sigma(grafo, palco, { renderLabels: true, hideEdgesOnMove: grafo.order > 5000, labelRenderedSizeThreshold: grafo.order > 5000 ? 9 : 3, enableCameraZooming: false }); renderer.setSetting("nodeReducer", (no, data) => (foco && no !== foco && !vizinhos.has(no) ? { color: esmaecido, label: null } : {})); renderer.on("enterNode", ({ node }) => { foco = node; vizinhos = new Set(grafo.neighbors(node)); renderer.refresh({ skipIndexation: true }); }); const layout = new FA2Layout(grafo, { settings: { ...forceAtlas2.inferSettings(grafo), barnesHutOptimize: grafo.order > 2000 } }); layout.start(); // o worker manda as posições a cada iteração; a página só desenha // Massivo na GPU: força e desenho nos shaders, 200 mil pontos const { Graph: Cosmos } = await import("@cosmos.gl/graph"); const gpu = new Cosmos(div, { spaceSize: 8192, simulationGravity: 0.35, simulationLinkSpring: 1.2, enableZoom: false }); gpu.setPointPositions(posicoes); gpu.setPointColors(coresRgba); gpu.setLinks(pares); gpu.render(); gpu.start(1);`, observar: [ "O layout roda no worker e a página continua desenhando a 60 quadros por segundo; o medidor mostra as duas taxas separadas.", "Os aglomerados que se formam são as comunidades: no grafo real, cada capítulo puxa os recursos que ensina.", "Acima de 5.000 nós as arestas somem durante o movimento e os rótulos ficam só nos maiores, e o quadro não cai.", "A GPU aguenta 200 mil pontos com força e desenho nos shaders, onde a CPU pararia em 20 mil.", ], }; ==== GrafoConhecimento.module.css ==== /* Laboratório grafo-conhecimento do curso Frontends com Vibecoding. Cor só por token do tema: contraste AA nos temas claro e escuro sem cor fixa. Alvo de toque de 44 px em todo controle e foco visível pelo acento do curso. */ .lab { display: grid; gap: 1rem; color: var(--text); } .paineis { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: 1rem; } .painel { display: grid; gap: 0.75rem; min-width: 0; padding: 0.9rem 1rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-muted); } .tituloPainel { margin: 0; font-size: 15px; line-height: 1.3; font-weight: 700; color: var(--text); } .explica, .estado, .nota, .aviso, .ficha { margin: 0; font-size: 14px; line-height: 1.55; color: var(--text); } .nota { font-size: 13px; color: var(--text-muted); } .aviso { padding: 0.6rem 0.8rem; border-left: 3px solid var(--warning); border-radius: var(--radius, 8px); background: var(--bg-white); font-size: 13px; } .aviso[data-reduzido="true"] { border-left-color: var(--success); } .controles { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; } .botao, .opcao { min-height: 44px; min-width: 44px; padding: 0 0.9rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); color: var(--text); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; } .botao[aria-pressed="true"], .opcao[aria-pressed="true"] { border-color: var(--course-accent, var(--accent)); box-shadow: inset 0 0 0 1px var(--course-accent, var(--accent)); color: var(--course-accent, var(--accent)); } .botao:disabled, .opcao:disabled { cursor: not-allowed; opacity: 0.55; } .botao:focus-visible, .opcao:focus-visible, .campo:focus-visible, .interruptor:focus-visible, .palco:focus-visible, .tabelaCaixa:focus-visible { outline: 2px solid var(--course-accent, var(--accent)); outline-offset: 2px; } @media (prefers-reduced-motion: no-preference) { .botao, .opcao { transition: border-color 160ms ease, color 160ms ease; } } .grupo { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; } .grupo > span { font-size: 13px; font-weight: 700; color: var(--text); } .interruptor { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 44px; padding: 0 0.6rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); font-size: 14px; color: var(--text); cursor: pointer; } .interruptor input { width: 20px; height: 20px; margin: 0; accent-color: var(--course-accent, var(--accent)); } .busca { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } .busca label { font-size: 13px; font-weight: 700; color: var(--text); } .campo { flex: 1 1 180px; min-height: 44px; padding: 0 0.7rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); color: var(--text); font: inherit; font-size: 14px; } .palco { position: relative; width: 100%; min-height: 320px; aspect-ratio: 4 / 3; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); overflow: hidden; touch-action: pan-y; } @media (min-width: 640px) { .palco { aspect-ratio: 16 / 9; } } /* O sigma cria as próprias camadas de canvas dentro do contêiner: todas ocupam o palco. */ .palco :global(canvas) { display: block; } .legenda { display: flex; flex-wrap: wrap; gap: 0.4rem 0.9rem; margin: 0; padding: 0; list-style: none; font-size: 13px; color: var(--text); } .legenda li { display: inline-flex; align-items: center; gap: 0.35rem; } .amostra { display: inline-block; width: 12px; height: 12px; border-radius: 999px; border: 1px solid var(--border); } .ficha { padding: 0.6rem 0.8rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); } .ficha a { color: var(--course-accent, var(--accent)); text-decoration: underline; text-underline-offset: 3px; } .estado { padding: 0.6rem 0.8rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); font-variant-numeric: tabular-nums; } .tabelaCaixa { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); } .tabela { width: 100%; border-collapse: collapse; font-size: 13px; color: var(--text); } .tabela caption { padding: 0.5rem 0.7rem; text-align: left; font-weight: 700; color: var(--text); } .tabela th, .tabela td { padding: 0.35rem 0.7rem; border-top: 1px solid var(--border); text-align: left; font-weight: 400; color: var(--text); } .tabela th { font-weight: 700; } .tabela td:last-child, .tabela th:last-child { text-align: right; font-variant-numeric: tabular-nums; }