Los dos grafos de conocimiento de MarkeUP, uno en 3D y otro en canvas 2D

doc-grafos-MarkeUP

Grafos de MarkeUP: ForceGraph 3D, Graphify y hover de enlaces

Los dos grafos de MarkeUP: ForceGraph en 3D y Graphify en canvas, su semántica de hover, las dependencias, el orden de arranque y los incidentes de carga ya resueltos.

Admin14 de septiembre de 2026, 00:008 min de lectura

Hay DOS páginas de grafo, con propósitos distintos y la misma semántica de hover:

PáginaComponenteMotorUso
/graphify/components/graph/Graphify.astroCanvas 2D + D3 (npm)Exploración densa, 365 nodos
/graph/components/graph/ForceGraph3D.astroThree.js + 3d-force-graphVista 3D inmersiva

Ambas muestran el vault completo (todas las secciones juntas). Desde septiembre de 2026 existen además las variantes por sección, que no salen de su blog (§0):

PáginaMuestra
/category/<Categoría>/graph/Grafo 3D solo de esa sección
/category/<Categoría>/graphify/Grafo 2D solo de esa sección

Los datos no se piden por red: se generan en build (src/lib/graph.ts → generateGraphData()) y se incrustan en el HTML como atributo data-graph. Esto elimina cualquier carrera con el Service Worker o con peticiones JSON.


filterGraphByCategory(data, categoría) recorta el grafo global a una sección:

  1. Se conservan los posts enlazados a cat:<Categoría> (la pertenencia se lee de los enlaces del grafo, así que soporta posts con varias categorías).
  2. Se conserva el nodo de esa categoría (el hub) y los tags que usan sus posts. Ningún otro nodo de categoría sobrevive.
  3. Los wikilinks se conservan solo si ambos extremos están en la sección: un enlace que sale a otro blog se descarta, para no romper el aislamiento.
  4. Los colores no se recalculan: una categoría y sus posts tienen el mismo color en /graph, en /graphify y en el grafo de su sección.

applySectionContextToGraph() añade ?from=<Categoría> a las URLs de los nodos de post, de modo que al entrar al artículo desde el grafo se mantenga el contexto de sección (ver doc-arquitectura).

getPostContent() construía la ruta del archivo como src/content/posts/<post.id>.md, pero el loader glob de Astro genera entry.id a partir del slug, sin carpeta ni extensión: para Humano/logica-aristotelica.md el id es logica-aristotelica. Esa ruta no existe, así que el readFileSync lanzaba, se capturaba en silencio y devolvía una cadena vacía: el grafo salía con 0 enlaces wikilink y 1.324 [[…]] en el vault (los wikilinks sí se renderizaban en los artículos porque el plugin de remark los lee de otra forma).

La solución es un índice del vault (getMarkdownIndex(), construido una vez por proceso) que mapea tanto la ruta relativa (Humano/logica-aristotelica) como el slug del archivo (logica-aristotelica) a su ruta real, y resolveWikilinkTarget() resuelve cada destino por slug directo, por alias del frontmatter ([[lowen]] → [[gerda-alexander-eutonia|alexander]]-lowen-bioenergetica) o por forma-slug ([[El Aura]] → el-aura). Resultado: 595 enlaces wikilink en el grafo global (de 678 a 1.273 enlaces totales).


"d3": "^7",                 // Graphify (Canvas 2D)
"3d-force-graph": "^1.80",  // /graph
"three": "^0.184",
"three-spritetext": "^1.10"

⚠️ D3 es una dependencia npm, NO un CDN. Antes se cargaba desde https://d3js.org/d3.v7.min.js en un <script is:inline>, y eso rompía /graphify en la primera visita (ver §3). Al empaquetarlo, Astro lo sirve desde el mismo origen y con hash, y https://d3js.org ya no está en el CSP.


Graphify.astro usa un <script> normal (módulo). Astro lo bundlea y difiere, así que d3 está garantizado antes de ejecutar. Aun así el arranque espera dos cosas para no medir un contenedor sin layout:

async function ready() {
  // 1. DOM listo
  if (document.readyState === 'loading') await new Promise(/* DOMContentLoaded */);
  // 2. El contenedor tiene tamaño REAL (si no, el canvas nacería 0×0)
  if (root.clientWidth > 0 && root.clientHeight > 0) return root;
  await new Promise((resolve) => { const ro = new ResizeObserver(/* … */); ro.observe(root); });
}

En /graph el equivalente es waitForSize() antes de crear el grafo.


Síntoma: al entrar por primera vez /graphify no renderizaba nada; con F5 funcionaba. Sin errores en consola.

Causa raíz: D3 se pedía a un CDN de otro origen en un <script is:inline> sin defer, y el script de arranque lo usaba enseguida (d3.forceSimulation(...)). En la primera visita d3 aún viajaba por red → ReferenceError → el IIFE moría en silencio (había return silenciosos). Al recargar, el navegador ya tenía d3 en su caché HTTP (cache-control: max-age=14400) → funcionaba.

Por qué no se veía el error: el fallo ocurría dentro de un IIFE y quedaba enterrado entre warnings. La leyenda (#gf-legend) quedaba vacía, que es la señal de que build() nunca llegó a ejecutarse.

Corrección: empaquetar D3 (npm i d3) y convertir el bloque en un módulo, más el arranque determinista de §2. Verificado midiendo píxeles reales del canvas: 0 píxeles antes → ~95 000 píxeles después (de 641 465), y la leyenda pasa de 0 a 594 caracteres.


No hay tooltips ni previsualizaciones. Al pasar el ratón por un nodo de tipo post se resaltan los posts a los que enlaza y los posts que enlazan a él.

La lógica es pura y testeada en src/lib/graph-hover.ts:

getHoverNeighborhood(id, nodes, links) // → { id, outgoing[], incoming[] }
highlightedIds(hood)                   // → Set de ids resaltados

Reglas: solo cuentan nodos type === 'post'; sin duplicados; nunca el propio nodo; acepta enlaces con extremos ya resueltos a nodo (d3 los muta). Complejidad O(E) en una sola pasada. Hay 11 tests en graph-hover.test.ts.

En 2D ([[graphify|Graphify]]): los nodos resaltados van a opacidad 1, el resto a 0.08; los enlaces que tocan el nodo suben a 0.55.

En 3D (ForceGraph3D): onNodeHover recalcula el conjunto y refresca nodeColor/linkColor/linkWidth. Se eliminó nodeLabel('label'), que era lo que producía el popup de previsualización.


Hay DOS fallos distintos y se tratan por separado:

Síntoma exacto en consola:

THREE.WebGLRenderer: A WebGL context could not be created. Reason: Could not create
a WebGL context, VENDOR = 0xffff, DEVICE = 0xffff,
GL_VENDOR = Disabled, GL_RENDERER = Disabled, Sandboxed = yes,
ErrorMessage = BindToCurrentSequence failed

GL_VENDOR = Disabled significa que el navegador no expone WebGL en absoluto. Causas típicas:

  • Brave con Shields → “Block fingerprinting: Strict” (es la nº1: desactiva WebGL para no filtrar la huella de la GPU).
  • Aceleración por hardware desactivada.
  • Flags del navegador (--disable-gpu no basta: Brave usa SwiftShader; hacen falta bloqueos más agresivos).

Esto NO se puede arreglar desde la web. Por eso initGraph lo comprueba antes de construir el grafo:

function webglAvailable() {
  try { const c = document.createElement('canvas');
        return !!(c.getContext('webgl2') || c.getContext('webgl')); }
  catch { return false; }
}

Si devuelve false, se muestra un aviso accionable (mensaje + causa probable

  • botón “Ver el grafo 2D”) y se sale sin intentar montar nada. Antes, el fallo salía como un stack trace opaco y la página quedaba en negro.

⚠️ Diagnóstico verificado: en un perfil de Brave limpio la página funciona (canvas 1199×535, sin aviso, 0 errores). El fallo es del entorno del navegador, no del sitio: la corrección correcta es informar bien, no “arreglar” el componente.

Es la pérdida normal de contexto (GPU ocupada, pestaña en segundo plano, límite de contextos). preventDefault() es obligatorio para que el navegador permita restaurarlo, y el grafo se recrea cuando vuelve:

container.addEventListener('webglcontextlost', (e) => {
  e.preventDefault();
  recoveryTimer = setTimeout(() => showFallback(), 8000);  // si no vuelve, se avisa
});
container.addEventListener('webglcontextrestored', () => {
  clearTimeout(recoveryTimer);
  container.querySelector('[data-graph-mount]')?.remove();
  initGraph();
});

Detalles que importan:

  • El grafo se monta en un hijo propio (atributo data-graph-mount), no en el contenedor. Así al recrearlo no se borra el nodo de fallback, que es hermano. (Con container.replaceChildren() el aviso quedaba huérfano y acababa tapando un grafo que sí funcionaba.)
  • hideFallback() al terminar bien la inicialización: un aviso mostrado por una pérdida transitoria debe retirarse cuando el grafo vuelve.
  • Si el constructor falla, se retira el montaje vacío (fail()) para no dejar un hueco en blanco sobre el aviso.
  • nodeResolution(12) reduce la presión de GPU y con ello la probabilidad de perder el contexto.
  • El contenedor tiene relative para que el aviso se superponga centrado.

npm test                    # 87 (incluye los 11 de graph-hover)
npm run test:a11y           # 11/11
npm run lint

Además se valida con Brave real vía CDP (12/12), incluyendo:

  • /graphify en primera visita: canvas con tamaño real y píxeles pintados (se cuenta el total, no puntos fijos: el layout es centrado y disperso, así que muestrear 9 puntos da falsos negativos).
  • 0 peticiones a d3js.org.
  • /graph: canvas creado, sin errores WebGL y sin fallback cuando hay WebGL.
  • Pérdida real de contexto (WEBGL_lose_context): se fuerza loseContext(), no aparece el fallback y el grafo se recrea tras restoreContext().
  • Sin WebGL (se anula getContext antes del script, equivalente a GL_VENDOR=Disabled): aparece el aviso, el mensaje dice que WebGL está desactivado, la pista menciona Brave/Shields, hay botón al grafo 2D y no queda ningún montaje huérfano en el DOM.

En esta misma línea, The School of Life aborda filosofía aplicada: Alain de Botton lleva el estoicismo, el existencialismo y la ética aristotélica al amor, la ansiedad por.

Este territorio conecta con Productividad en GPTs, donde se explora cómo usar GPTs personalizados para la productividad: la evolución de los agentes personales, los flujos de trabajo, la.

doc-grafos-MarkeUP

Explora las ramas del artículo

Compartir este artículo