Saltar al contenido

Construir un eje a mano

Las cinco piezas de un eje, el desplazamiento de medio píxel que lo hace nítido, el formato de los números según la magnitud, y qué hacer cuando las etiquetas no caben.

Las cinco piezas

Un eje tiene:

  1. La línea del dominio: el segmento que recorre el eje.
  2. Las marcas: los trazos cortos perpendiculares.
  3. Las etiquetas: los números o textos junto a cada marca.
  4. La rejilla: las líneas que atraviesan el área de dibujo. Opcional y a menudo mejor que las marcas.
  5. El título del eje: la magnitud y la unidad.

Y una regla de alineación por orientación:

Orientación text-anchor Desplazamiento de la etiqueta
Izquierda end x negativo, dy de 0,32em
Derecha start x positivo, dy de 0,32em
Abajo middle y positivo, dy de 0,71em
Arriba middle y negativo, dy de 0

Esos valores de dy son los mismos que usa la biblioteca de referencia del sector, y salen de compensar la línea base: 0,32em centra verticalmente un texto de una línea, y 0,71em lo baja hasta que su parte superior queda a la altura de la marca.

El generador

JS
const NS = 'http://www.w3.org/2000/svg';const el = (n, a = {}, h = []) => {  const e = document.createElementNS(NS, n);  for (const [k, v] of Object.entries(a)) if (v != null) e.setAttribute(k, v);  for (const c of h) e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);  return e;};function eje({ escala, marcas, orientacion = 'abajo', longitud = 6,               formato = String, extension = 0 }) {  const horizontal = orientacion === 'arriba' || orientacion === 'abajo';  const signo = (orientacion === 'arriba' || orientacion === 'izquierda') ? -1 : 1;  const [r0, r1] = escala.rango ? escala.rango() : [0, 0];  const anclaje = horizontal ? 'middle' : (signo < 0 ? 'end' : 'start');  const dy = orientacion === 'abajo' ? '0.71em'           : orientacion === 'arriba' ? '0em' : '0.32em';  const g = el('g', { class: `eje eje-${orientacion}`, 'font-size': 11,                      fill: 'none', 'text-anchor': anclaje });  // 1. Linea del dominio  g.appendChild(el('path', {    class: 'dominio',    d: horizontal      ? `M${r0} 0H${r1}`      : `M0 ${r0}V${r1}`,    stroke: 'currentColor',    'shape-rendering': 'crispEdges',  }));  for (const m of marcas) {    const p = escala(m);    const grupo = el('g', {      class: 'marca',      transform: horizontal ? `translate(${p},0)` : `translate(0,${p})`,    });    // 2. Marca    grupo.appendChild(el('line', {      [horizontal ? 'y2' : 'x2']: signo * longitud,      stroke: 'currentColor',      'shape-rendering': 'crispEdges',    }));    // 4. Rejilla, si se pide    if (extension) {      grupo.appendChild(el('line', {        class: 'rejilla',        [horizontal ? 'y2' : 'x2']: -signo * extension,        stroke: 'currentColor',        'stroke-opacity': 0.15,        'shape-rendering': 'crispEdges',      }));    }    // 3. Etiqueta    grupo.appendChild(el('text', {      [horizontal ? 'y' : 'x']: signo * (longitud + 4),      dy,      fill: 'currentColor',    }, [formato(m)]));    g.appendChild(grupo);  }  return g;}

Tres decisiones del código merecen comentario.

Cada marca es un g con transform. Así la posición se escribe una vez y la marca, la rejilla y la etiqueta se colocan en coordenadas locales. Simplifica el código y facilita la animación: mover una marca es cambiar un transform.

fill: none en el grupo y fill: currentColor en el texto. Sin el primero, las líneas del eje se rellenarían; sin el segundo, el texto sería invisible. Es el patrón estándar y no es evidente.

shape-rendering: crispEdges desactiva el antialiasing en las líneas del eje. Con él, una línea horizontal de un píxel se ve nítida; sin él, si cae entre dos píxeles, se ve como dos líneas grises. Es la solución simple al problema del medio píxel.

La alternativa clásica es desplazar medio píxel: dibujar la línea en y = 100.5 en lugar de y = 100, de modo que el centro del trazo de un píxel caiga en el centro de una fila de píxeles. Funciona y depende de que la escala del viewBox sea uno a uno, lo que en un gráfico responsivo no se cumple. crispEdges es más robusto.

El formato de los números

Un eje con etiquetas 1000000, 2000000, 3000000 es ilegible. El formato tiene que adaptarse a la magnitud del dominio.

JS
function formateador(dominio, unidad = '') {  const max = Math.max(...dominio.map(Math.abs));  const paso = Math.abs(dominio[1] - dominio[0]);  // Decimales necesarios para distinguir dos marcas consecutivas  const dec = Math.max(0, Math.min(3, -Math.floor(Math.log10(paso)) + 1));  if (max >= 1e9) return v => `${(v / 1e9).toFixed(dec)} mM${unidad}`;  if (max >= 1e6) return v => `${(v / 1e6).toFixed(dec)} M${unidad}`;  if (max >= 1e3) return v => `${(v / 1e3).toFixed(dec)} k${unidad}`;  const nf = new Intl.NumberFormat('es-ES', {    minimumFractionDigits: dec, maximumFractionDigits: dec,  });  return v => nf.format(v) + unidad;}

Dos cosas importantes ahí.

Los decimales salen del paso entre marcas, no del valor. Si las marcas van de 0,25 en 0,25, hace falta un decimal; si van de 100 en 100, ninguno. Calcularlo del valor máximo produce etiquetas con decimales inútiles o sin los necesarios.

El separador decimal y de millares vienen de Intl. En español el decimal es la coma y el millar el punto. Escribirlo a mano con toFixed produce el formato anglosajón, que en un gráfico en español es un error.

El prefijo de magnitud (k, M) tiene que aparecer también en el título del eje, no solo en las etiquetas, o el lector no sabe de qué unidad se habla. Mejor todavía: poner la unidad en el título («Ventas, en miles de euros») y dejar las etiquetas limpias.

Cuando las etiquetas no caben

Tres estrategias, en orden de preferencia.

Uno: menos marcas. Es casi siempre la respuesta correcta y la última que se prueba. Un eje con cuatro marcas se lee mejor que uno con doce apretadas. El algoritmo de marcas bonitas acepta un número objetivo; bájalo.

Dos: rotar las etiquetas. Funciona y empeora la legibilidad: leer texto inclinado es más lento. Si hay que hacerlo, 45 grados y no 90, y hacia arriba para que el punto de anclaje quede junto a la marca.

JS
texto.setAttribute('transform', 'rotate(-45)');texto.setAttribute('text-anchor', 'end');texto.setAttribute('dx', '-0.4em');texto.setAttribute('dy', '0.2em');

Tres: abreviar o truncar. «Septiembre» a «sep». Es la mejor opción para etiquetas categóricas y hay que hacerlo con criterio, no cortando por longitud fija.

Y una cuarta que casi nadie considera y suele ser la mejor: cambiar la orientación del gráfico. Un gráfico de barras horizontales tiene las etiquetas de categoría en el eje vertical, donde hay sitio de sobra para textos largos. Si tus categorías son nombres de producto o de país, las barras horizontales son la respuesta correcta y ahorran todo el problema.