El enfoque híbrido y el árbol de decisión
Dos capas superpuestas con el mismo sistema de coordenadas: lienzo para los datos densos, SVG para ejes, etiquetas e interacción, con el código de alineación que hace que no se descuadren.
El híbrido no es un compromiso entre dos opciones peores: es la asignación correcta de cada tipo de contenido al medio que lo dibuja mejor. Los datos son muchos y no necesitan semántica; los ejes, las etiquetas y las anotaciones son pocos y la necesitan toda. Superponer las dos capas cuesta unas veinte líneas de configuración, y esas veinte líneas son también donde está el único fallo serio que tiene el patrón.
- Montar dos capas superpuestas que compartan sistema de coordenadas.
- Repartir el contenido entre lienzo y SVG con un criterio defendible.
- Alinear las dos capas contando con el ratio de píxeles y los márgenes.
- Recorrer el árbol de decisión completo entre lienzo, SVG e híbrido.
El reparto
La regla, que ya salía en los tres artículos anteriores y aquí se hace explícita:
Al lienzo van las marcas de datos: los puntos de una dispersión, las líneas de una serie densa, las celdas de un mapa de calor, las áreas de un treemap grande. Son muchas, cambian juntas y no necesitan identidad individual en el DOM.
Al SVG va todo lo demás: la línea de los ejes, las marcas, las etiquetas numéricas, los títulos, la rejilla, las líneas de referencia, las anotaciones, la leyenda, el resaltado de la marca bajo el puntero, la retícula, el rectángulo de selección y las zonas de acierto ampliadas.
El criterio en una frase: si hay menos de unos cientos y lleva texto o interacción, va arriba en SVG.
Y una tercera capa que a veces conviene: un segundo lienzo para lo que cambia en cada fotograma. Si los datos son estáticos y solo se mueve el resaltado, tener el fondo en un lienzo que se dibuja una vez y el resaltado en otro encima evita repintar cien mil marcas por cada movimiento del ratón.
El montaje
<div class="gr" style="position:relative;width:100%;height:360px">
<canvas class="gr-datos" style="position:absolute;inset:0;width:100%;height:100%"></canvas>
<svg class="gr-encima" style="position:absolute;inset:0;width:100%;height:100%"></svg>
</div>
const NS = 'http://www.w3.org/2000/svg';
const M = { arriba: 16, dcha: 96, abajo: 32, izda: 48 };
function montar(caja) {
const lienzo = caja.querySelector('.gr-datos');
const svg = caja.querySelector('.gr-encima');
const ctx = lienzo.getContext('2d');
function dimensionar() {
const { width: w, height: h } = caja.getBoundingClientRect();
const dpr = devicePixelRatio;
// Capa de datos: buffer en pixeles de dispositivo
lienzo.width = Math.round(w * dpr);
lienzo.height = Math.round(h * dpr);
// Una sola transformacion: escala del dispositivo mas el margen
ctx.setTransform(dpr, 0, 0, dpr, M.izda * dpr, M.arriba * dpr);
// Capa de encima: coordenadas CSS, uno a uno
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
return { w: w - M.izda - M.dcha, h: h - M.arriba - M.abajo };
}
return { lienzo, svg, ctx, dimensionar };
}
Cuatro decisiones de ese código que son las que hacen que funcione.
El viewBox del SVG está en píxeles CSS, uno a uno. Así una coordenada vale lo mismo en las dos capas y no hay ninguna conversión que recordar. Un viewBox con otras unidades funciona igual de bien y obliga a convertir en cada punto de contacto, que es donde entran los errores.
El margen está dentro de la transformación del contexto. Con setTransform(dpr, 0, 0, dpr, M.izda * dpr, M.arriba * dpr), todo lo que dibujes en el lienzo usa coordenadas relativas al área de dibujo, igual que el grupo desplazado de SVG. Es el convenio de márgenes de el patrón de componente de gráfico aplicado a las dos capas a la vez. Fíjate en que el desplazamiento va multiplicado por el ratio, porque setTransform sustituye la matriz entera y sus dos últimos componentes están en píxeles de dispositivo.
El grupo del SVG lleva el mismo desplazamiento.
const g = document.createElementNS(NS, 'g');
g.setAttribute('transform', `translate(${M.izda},${M.arriba})`);
Los eventos. Decide una capa que los reciba y desactiva la otra. Lo habitual es que los reciba el SVG, porque ahí están las zonas de acierto y los elementos enfocables:
.gr-datos { pointer-events: none; }
Si prefieres resolverlos en el lienzo con hit testing, invierte el pointer-events y pon none en el SVG, salvo en los elementos concretos que sí deban recibirlos.
El árbol de decisión
flowchart TB A[Necesitas texto seleccionable impresion vectorial o semantica] -->|si y es viable| B[SVG] A -->|no| C[Cuantos elementos dibujas] C -->|menos de mil| B C -->|entre mil y diez mil| D[Cambian en cada fotograma] C -->|mas de diez mil| E[Lienzo para los datos] D -->|no| B D -->|si| E E --> F[Hacen falta ejes etiquetas o anotaciones] F -->|no| G[Lienzo puro] F -->|si| H[Hibrido lienzo para los datos y SVG para el resto] style A fill:#cba6f7,color:#11111b style B fill:#a6e3a1,color:#11111b style C fill:#89b4fa,color:#11111b style D fill:#f9e2af,color:#11111b style E fill:#fab387,color:#11111b style F fill:#f9e2af,color:#11111b style G fill:#fab387,color:#11111b style H fill:#94e2d5,color:#11111b
Tres lecturas del árbol que conviene tener presentes.
La primera pregunta manda sobre todas las demás. Si el gráfico se imprime en un informe, o si tiene que ser navegable con lector de pantalla, o si va en un correo, el recuento de elementos es secundario: la respuesta es SVG, y si hay demasiados elementos, el trabajo consiste en reducirlos (agregando, filtrando, muestreando) y no en cambiar de medio.
La rama del híbrido es la que más se elige en la práctica para gráficos de producción con datos densos, porque «hacen falta ejes y etiquetas» es cierto casi siempre.
«Lienzo puro» es una hoja legítima, no un descuido: una visualización artística, un fondo generativo, un minimapa, un juego. Lo que la caracteriza es que no hay nada que leer.
Y una cuarta salida que el árbol no muestra porque queda fuera de este track: cuando ni siquiera el lienzo 2D llega, el siguiente paso es WebGL o WebGPU, con la rasterización hecha por la GPU. El punto de corte suele estar en el orden de los cientos de miles de marcas animadas.
El híbrido funciona a la primera y luego alguien mira de cerca y ve que la rejilla del SVG no pasa exactamente por donde debería pasar respecto a las barras del lienzo. Medio píxel. Y en el borde inferior de un gráfico de barras, medio píxel es la diferencia entre que las barras se apoyen en el eje y que floten.
Las cuatro causas, en el orden en que aparecen:
Uno: el redondeo del tamaño del búfer. getBoundingClientRect devuelve fraccionarios. lienzo.width es un entero, así que Math.round(w * dpr) redondea, y el búfer acaba representando un ancho CSS ligeramente distinto del que el SVG tiene en su viewBox. En una caja de 800,4 píxeles con ratio 2, el búfer mide 1601 y representa 800,5. La deriva es proporcional a la distancia al origen, así que en el extremo derecho del gráfico es máxima. La defensa: calcula el ancho lógico a partir del búfer, no al revés, y escribe ese mismo valor en el viewBox.
lienzo.width = Math.round(w * dpr);
const anchoLogico = lienzo.width / dpr; // el ancho de verdad
svg.setAttribute('viewBox', `0 0 ${anchoLogico} ${altoLogico}`);Dos: el problema del medio píxel del trazo. Una línea de un píxel dibujada en una coordenada entera se reparte entre dos filas de píxeles y se ve como dos líneas grises. En SVG se resuelve con shape-rendering: crispEdges, como se establece en construir un eje a mano; en el lienzo, con el desplazamiento de medio píxel. Las dos soluciones no coinciden: crispEdges ajusta la línea al píxel más cercano según su propio criterio, y tu desplazamiento manual la ajusta según el tuyo. Cuando una rejilla en SVG tiene que alinearse con el borde de una barra del lienzo, hay que usar el mismo criterio en las dos capas, y el que se puede controlar es el del lienzo. Por eso, en un híbrido, la rejilla suele ir también en el lienzo, aunque las etiquetas vayan en SVG.
Tres: la transformación del contexto no es acumulativa. setTransform sustituye la matriz. Si en algún punto del dibujado llamas a translate o a scale, y luego a setTransform para «restaurar», pierdes el margen. Y si usas save y restore sin cuidado, acabas con un desplazamiento aplicado dos veces. La disciplina que funciona: una sola llamada a setTransform por fotograma, al principio, y a partir de ahí solo save y restore.
Cuatro: el ratio cambia y solo se entera una capa. Al arrastrar la ventana a un monitor con otra densidad, el SVG se readapta solo y el lienzo se queda con el búfer del monitor anterior. No es un descuadre de posición, es un descuadre de nitidez, y se corrige vigilando el ratio como se explica en accesibilidad, impresión y zoom.
Y la comprobación que detecta las cuatro en diez segundos: dibuja un rectángulo de un píxel en el borde exacto del área de dibujo en las dos capas, con colores distintos, y amplía al 400 por ciento. Si los dos rectángulos no se solapan exactamente, tienes uno de los cuatro problemas y el desplazamiento visible te dice cuál. Deja ese rectángulo detrás de una bandera de depuración: cuesta cuatro líneas y vas a volver a necesitarlo.
Convierte un gráfico tuyo de SVG puro a híbrido: deja los ejes y las etiquetas donde están y mueve solo las marcas de datos a un lienzo debajo. Después activa la comprobación del rectángulo de un píxel y amplía al 400 por ciento antes de dar por bueno el resultado. Mide el tiempo de fotograma antes y después con diez mil marcas: la diferencia es la que justifica el patrón.