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.
Navegando en el blogver navegación en todos los blogs
Hay DOS páginas de grafo, con propósitos distintos y la misma semántica de hover:
| Página | Componente | Motor | Uso |
|---|---|---|---|
/graphify/ | components/graph/Graphify.astro | Canvas 2D + D3 (npm) | Exploración densa, 365 nodos |
/graph/ | components/graph/ForceGraph3D.astro | Three.js + 3d-force-graph | Vista 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ágina | Muestra |
|---|---|
/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:
- 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). - 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.
- 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.
- Los colores no se recalculan: una categoría y sus posts tienen el mismo
color en
/graph, en/graphifyy 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.jsen un<script is:inline>, y eso rompía/graphifyen la primera visita (ver §3). Al empaquetarlo, Astro lo sirve desde el mismo origen y con hash, yhttps://d3js.orgya 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-gpuno 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. (Concontainer.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
relativepara 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:
/graphifyen 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 fuerzaloseContext(), no aparece el fallback y el grafo se recrea trasrestoreContext(). - Sin WebGL (se anula
getContextantes del script, equivalente aGL_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
