El algoritmo de las marcas bonitas
Por qué las marcas de un eje solo pueden ser 1, 2 o 5 por una potencia de diez, cómo se elige el incremento con medias geométricas, y por qué el número de marcas que pides nunca es el que recibes.
Un eje que va de 0 a 247 con diez marcas produce etiquetas cada 24,7 unidades, y ese eje es ilegible aunque sea aritméticamente impecable. El algoritmo que convierte un dominio arbitrario en marcas que un humano puede leer cabe en quince líneas, tiene una justificación en logaritmos que rara vez se explica, y esconde un detalle de coma flotante que decide si tu eje dice 0,6 o dice 0,6000000000000001.
- Explicar por qué los incrementos aceptables son 1, 2 y 5 por una potencia de diez.
- Implementar
incrementoDeMarcacon las tres medias geométricas como umbrales. - Generar las marcas por multiplicación entera para evitar el error acumulado.
- Redondear un dominio con un bucle y saber por qué hace falta iterar.
Por qué solo 1, 2 y 5
La tarea que hace el lector de un eje no es leer las etiquetas: es interpolar entre ellas. Cuando una barra acaba entre la marca de 40 y la de 60, el lector estima mentalmente cuánto vale. Esa estimación es trivial si el hueco entre marcas vale 20 y es una tortura si vale 24,7.
Un número bonito es un número de la forma m × 10^k donde la mantisa m es 1, 2 o 5. Con cualquiera de los tres, la subdivisión mental es inmediata: con paso 1 el lector cuenta décimas, con paso 2 parte por la mitad, con paso 5 sabe dónde cae el 2,5. Con paso 3 o paso 7 no hay atajo aritmético, y el eje deja de ser una regla para convertirse en un acertijo.
Hay un segundo motivo, más silencioso: los incrementos bonitos producen etiquetas cortas. Un paso de 20 sobre un dominio que empieza en 0 genera 0, 20, 40, 60. Un paso de 24,7 genera 24,7, 49,4, 74,1, y esas etiquetas ocupan el triple de ancho, lo que fuerza a rotarlas, lo que empeora la lectura otra vez.
Algunas implementaciones añaden 2,5 a la lista. Es defendible (2,5 se subdivide bien) y tiene un coste: mezcla dos familias de mantisas y hace que ejes de dominios parecidos elijan pasos que no son múltiplos entre sí, lo que rompe la comparación visual entre dos gráficos contiguos. La versión de tres mantisas es la del sector y la que vas a implementar aquí.
El incremento
El problema concreto: dados d0, d1 y un número objetivo de marcas, devolver el incremento bonito más cercano al ideal.
const E10 = Math.sqrt(50); // 7.0710678...
const E5 = Math.sqrt(10); // 3.1622776...
const E2 = Math.sqrt(2); // 1.4142135...
function incrementoDeMarca(d0, d1, contador = 10) {
const paso = (d1 - d0) / Math.max(1, contador);
const potencia = Math.floor(Math.log10(paso));
const mantisa = paso / Math.pow(10, potencia); // queda en el intervalo de 1 a 10
const factor = mantisa >= E10 ? 10
: mantisa >= E5 ? 5
: mantisa >= E2 ? 2
: 1;
return potencia >= 0
? factor * Math.pow(10, potencia)
: -Math.pow(10, -potencia) / factor;
}
Dos cosas ahí merecen la explicación que casi nunca se da.
Los umbrales son medias geométricas, no aritméticas. Para decidir si una mantisa de 3,4 se redondea a 2 o a 5, la pregunta correcta no es cuál está más cerca en distancia, sino cuál está más cerca en proporción: un eje con paso 2 tiene 1,7 veces más marcas de las pedidas, y uno con paso 5 tiene 1,47 veces menos. El punto donde los dos errores relativos se igualan es la raíz de 2 × 5, que es Math.sqrt(10). Lo mismo entre 1 y 2 (Math.sqrt(2)) y entre 5 y 10 (Math.sqrt(50)). Un algoritmo que usara los puntos medios aritméticos (1,5, 3,5, 7,5) produciría ejes sistemáticamente más densos de lo pedido, porque en un espacio multiplicativo la media aritmética está sesgada hacia arriba.
El signo del retorno codifica una división. Cuando el incremento es menor que uno, la función no devuelve 0,2: devuelve -5. Es decir, devuelve el recíproco con signo negativo, y quien lo consume sabe que un valor negativo significa «divide por esto» en lugar de «multiplica por esto». Ese truco, que parece una fealdad, es la solución al problema de la siguiente sección.
La coma flotante y la generación de marcas
Un eje de 0 a 1 con paso 0,2. La forma ingenua de generar las marcas es sumar:
for (let v = d0; v <= d1; v += paso) marcas.push(v);
Y produce esto:
0, 0.2, 0.4, 0.6000000000000001, 0.8, 1
El error se acumula porque 0,2 no es representable en binario y cada suma arrastra el residuo de la anterior. La segunda forma ingenua, multiplicar el índice por el paso, mejora pero no resuelve: 3 * 0.2 sigue dando 0.6000000000000001, porque el 0.2 que multiplicas ya venía redondeado.
La solución es dividir en lugar de multiplicar. 3 / 5 da exactamente 0.6, porque la división en coma flotante devuelve el double más cercano al cociente verdadero, y el double más cercano a 0,6 es el que se imprime como 0.6. Por eso el incremento se devuelve como recíproco cuando es menor que uno.
function marcas(d0, d1, contador = 10) {
if (!(d1 > d0)) return [d0];
const inc = incrementoDeMarca(d0, d1, contador);
const salida = [];
if (inc > 0) {
const i0 = Math.ceil(d0 / inc), i1 = Math.floor(d1 / inc);
for (let i = i0; i <= i1; i++) salida.push(i * inc);
} else {
const inv = -inc;
const i0 = Math.ceil(d0 * inv), i1 = Math.floor(d1 * inv);
for (let i = i0; i <= i1; i++) salida.push(i / inv);
}
return salida;
}
marcas(0, 1, 5); // [0, 0.2, 0.4, 0.6, 0.8, 1]
marcas(0, 247, 10); // [0, 20, 40, ... 240] -> 13 marcas
marcas(0, 247, 5); // [0, 50, 100, 150, 200] -> 5 marcas
Fíjate en que el bucle recorre índices enteros, no valores. i0 e i1 son los índices de la primera y la última marca que caen dentro del dominio, y cada valor se calcula desde cero a partir de su índice. No hay acumulación posible porque no hay nada que acumular.
Esto no es cosmética. Una etiqueta que dice 0.6000000000000001 es un fallo visible en producción, y el parche habitual (toFixed(2) sobre todas las etiquetas) rompe los ejes de otras magnitudes, donde dos decimales fijos son o insuficientes o absurdos.
Redondear el dominio, y por qué hay que iterar
marcas(3.7, 247.2, 10) empieza en 20 y acaba en 240: los extremos del dominio quedan sin etiquetar y el gráfico tiene aire sin marcar arriba y abajo. La solución es extender el dominio hasta múltiplos del incremento antes de generar las marcas.
function redondearDominio(d0, d1, contador = 10) {
let previo = NaN;
for (let k = 0; k < 10; k++) {
const inc = incrementoDeMarca(d0, d1, contador);
if (inc === previo || inc === 0 || !Number.isFinite(inc)) break;
if (inc > 0) {
d0 = Math.floor(d0 / inc) * inc;
d1 = Math.ceil(d1 / inc) * inc;
} else {
const inv = -inc;
d0 = Math.floor(d0 * inv) / inv;
d1 = Math.ceil(d1 * inv) / inv;
}
previo = inc;
}
return [d0, d1];
}
redondearDominio(3.7, 247.2, 5); // [0, 250]
redondearDominio(3.7, 247.2, 10); // [0, 260]
redondearDominio(0.023, 0.087, 5); // [0.02, 0.09]
redondearDominio(-3.2, 58.1, 6); // [-10, 60]
El bucle es la parte que sorprende. ¿Por qué no basta con una pasada? Porque extender el dominio cambia el incremento ideal. Si el dominio pasa de tener una amplitud de 243,5 a tener 250, el paso ideal para diez marcas pasa de 24,35 a 25, y eso puede cruzar uno de los umbrales geométricos y devolver otro incremento. Iterando hasta que el incremento se estabiliza se llega a un punto fijo. El límite de diez vueltas es una red de seguridad: en la práctica converge en dos.
Fíjate también en que el resultado depende del número objetivo de marcas. Con objetivo 5 el dominio se redondea a [0, 250]; con objetivo 10, a [0, 260]. No hay una respuesta única a «cuál es el dominio bonito»: hay una respuesta por cada densidad de eje que pidas. Por eso el redondeo tiene que hacerse con el mismo contador que después genera las marcas, o el eje sale con los extremos descuadrados.
Sobre cuántas marcas pedir: la regla que funciona es una marca cada 50 a 80 píxeles en el eje horizontal y cada 40 a 50 en el vertical, porque las etiquetas horizontales compiten por ancho y las verticales solo por alto. Es decir, el contador no es una constante, es una función del tamaño:
const objetivo = Math.max(2, Math.round(alto / 45));
const ticks = marcas(...dominio, objetivo);
Un eje responsivo con el contador fijo en 10 se convierte en un amasijo de etiquetas al reducir el contenedor. Con el contador derivado del tamaño, se adelgaza solo.
marcas(0, 247, 10) devuelve trece marcas. marcas(0, 247, 5) devuelve cinco. El contador no es un contrato: es la entrada de una división cuyo resultado se redondea a la mantisa bonita más cercana, y el número de marcas resultante puede ser desde la mitad hasta el doble del pedido. Esto está bien documentado en las bibliotecas y aun así sorprende a todo el mundo la primera vez.
El noventa por ciento del tiempo da igual. El diez por ciento restante es el caso en que necesitas que dos ejes distintos tengan sus líneas de rejilla alineadas, y ahí el algoritmo te deja tirado:
Dos gráficos apilados que comparten el eje horizontal pero tienen magnitudes verticales distintas. Si cada uno calcula sus marcas por su cuenta, las rejillas horizontales caen a alturas distintas y el conjunto se ve sucio.
Un panel de pequeños múltiplos donde cada celda tiene su propio dominio. Si el número de marcas varía entre celdas, la altura del área de dibujo varía con ella y las celdas dejan de estar alineadas.
El eje dual, que ya tiene bastantes problemas propios sin añadirle rejillas cruzadas.
La técnica correcta cuando necesitas exactamente n marcas es invertir el problema: no busques el incremento a partir del dominio, busca el dominio a partir del incremento.
function dominioParaNMarcas(d0, d1, n) {
const paso = incrementoDeMarca(d0, d1, n - 1);
const unidad = paso > 0 ? paso : 1 / -paso;
const inicio = Math.floor(d0 / unidad) * unidad;
return [inicio, inicio + unidad * (n - 1)];
}Se elige un incremento bonito, se ancla el extremo inferior a un múltiplo suyo, y se extiende el extremo superior lo que haga falta para que quepan exactamente n - 1 intervalos. El precio es aire extra en la parte alta del gráfico; la ganancia es que todas las celdas del panel tienen la misma cuadrícula y se pueden comparar de un vistazo.
Y el matiz que decide si esto es aceptable: funciona con dominios anclados en cero o en un mínimo redondeado, y falla con dominios ajustados a los datos. Si el extremo inferior también tiene que ser bonito y ajustado, no siempre existe un par de números bonitos con exactamente n - 1 intervalos entre ellos, y hay que ceder en algo. Cede en el extremo superior: un poco de aire arriba no engaña a nadie, y una rejilla desalineada entre seis paneles sí estropea la lectura de todos.
Implementa incrementoDeMarca y sustituye los umbrales geométricos por los aritméticos (1,5, 3,5 y 7,5). Genera las marcas de treinta dominios aleatorios con objetivo 10 con las dos versiones y cuenta cuántas marcas devuelve cada una de media. La versión aritmética saldrá sistemáticamente por encima de diez, y esa diferencia medida es la mejor explicación de por qué el algoritmo real usa raíces cuadradas.