wandres.dev
VISUALIZACIÓN DE DATOS I · Escalas y ejes

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.

⏱ 18 min

Un eje parece trivial hasta que lo escribes: son cinco piezas, cada una con su alineación, su formato y su caso límite, y su calidad decide si el gráfico se lee bien o se lee a duras penas. Escribirlo a mano una vez, en lugar de invocar el de una biblioteca, enseña exactamente qué decisiones lleva dentro y por qué las bibliotecas hacen lo que hacen.

🎯 Al terminar esta lección sabrás
  • Enumerar las cinco piezas de un eje y sus reglas de alineación.
  • Escribir un generador de eje reutilizable para las cuatro orientaciones.
  • Formatear las etiquetas numéricas según la magnitud del dominio.
  • Resolver el solapamiento de etiquetas con las tres estrategias.

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

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.

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.

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.

La rejilla y las marcas hacen el mismo trabajo, y ponerlas las dos es ruido

Un eje con línea de dominio, marcas, rejilla y etiquetas tiene cuatro elementos gráficos por cada valor, y tres de ellos dicen lo mismo: dónde cae ese valor.

El criterio para elegir, y es un criterio de tarea, no de gusto:

Si la tarea es leer valores concretos («cuánto vendimos en marzo»), la rejilla ayuda: permite seguir horizontalmente desde la barra hasta el eje. Entonces la rejilla se queda y las marcas sobran.

Si la tarea es comparar formas o tendencias («cómo evolucionó»), la rejilla estorba: añade líneas que compiten con los datos. Entonces las marcas se quedan y la rejilla sobra.

La línea de dominio sobra casi siempre. Si hay rejilla, la primera línea de la rejilla ya marca el borde. Si hay marcas, las marcas ya indican el eje. La línea de dominio es un residuo de los gráficos dibujados a mano, donde el eje era el marco de referencia físico.

La excepción es el eje horizontal de un gráfico con valores negativos: ahí la línea del cero es información, porque separa lo positivo de lo negativo. Y en ese caso debe destacarse respecto a las demás líneas de la rejilla, con más contraste o más grosor.

Esto no es minimalismo por el minimalismo: cada línea que quitas es tinta que deja de competir con los datos, y en un panel con seis gráficos la diferencia entre ejes limpios y ejes cargados es la diferencia entre poder mirar los datos y tener que buscarlos.

La configuración que funciona para el noventa por ciento de los casos: rejilla horizontal tenue, sin marcas, sin línea de dominio en el eje de valores; y en el eje de categorías, ni rejilla ni línea, solo las etiquetas. Dos elementos gráficos en total, y el gráfico se lee.

⚔️ Reto práctico

Escribe el generador de eje de este artículo y úsalo para las cuatro orientaciones en el mismo gráfico. Después genera tres versiones del mismo eje vertical: con marcas, con rejilla, y con las dos. Ponlas al lado y decide cuál usarías para un panel donde la tarea es comparar meses, y cuál para uno donde la tarea es leer el valor exacto de un mes concreto.