Qué es una escala
La abstracción que separa el dominio de los datos del rango de la pantalla: la función, su inversa, el recorte, y por qué escribirla como objeto cambia la arquitectura de un gráfico.
Una escala es una función de un dominio a un rango, y esa definición de una línea es la pieza que separa un gráfico mantenible de un montón de fórmulas repartidas por el código. En cuanto la conversión de valor a píxel vive en un objeto con nombre, el gráfico se puede testar, se puede invertir para el hit testing, y se puede cambiar de tipo sin tocar el resto.
- Definir una escala como función de dominio a rango con su inversa.
- Implementar una escala lineal con
invert,clampynice. - Explicar por qué la inversa es imprescindible para la interacción.
- Reconocer el olor de un gráfico con la conversión dispersa por el código.
La definición
Un dominio es el conjunto de valores de los datos. Un rango es el conjunto de valores de la representación. Una escala es la función que va del primero al segundo.
escala: dominio -> rango
En un gráfico de ventas mensuales, el dominio del eje vertical podría ser [0, 250] (miles de euros) y el rango [300, 0] (píxeles, del fondo del área de dibujo a su parte superior). La escala convierte 125 en 150.
Fíjate en que el rango está invertido: empieza en 300 y acaba en 0. No es un error: en pantalla la coordenada y crece hacia abajo, y queremos que los valores grandes queden arriba. Escribir el rango invertido resuelve la inversión de una vez, en un sitio, en lugar de esparcir restas por el código.
La implementación mínima
function escalaLineal({ dominio = [0, 1], rango = [0, 1] } = {}) {
let [d0, d1] = dominio;
let [r0, r1] = rango;
let recortar = false;
function escala(x) {
if (d1 === d0) return (r0 + r1) / 2; // dominio degenerado
let t = (x - d0) / (d1 - d0);
if (recortar) t = Math.min(1, Math.max(0, t));
return r0 + t * (r1 - r0);
}
escala.invert = (y) => {
if (r1 === r0) return (d0 + d1) / 2;
let t = (y - r0) / (r1 - r0);
if (recortar) t = Math.min(1, Math.max(0, t));
return d0 + t * (d1 - d0);
};
escala.dominio = (v) => (v ? ([d0, d1] = v, escala) : [d0, d1]);
escala.rango = (v) => (v ? ([r0, r1] = v, escala) : [r0, r1]);
escala.recortar = (v) => (v === undefined ? recortar : (recortar = v, escala));
return escala;
}
Uso:
const y = escalaLineal({ dominio: [0, 250], rango: [300, 0] });
y(125); // 150
y(0); // 300
y(250); // 0
y.invert(150); // 125
Ese patrón de accesores que devuelven la propia función cuando reciben un argumento es el que usa la biblioteca estándar del sector, y permite encadenar:
const x = escalaLineal().dominio([0, 12]).rango([0, ancho]);
Por qué la inversa es imprescindible
invert no es un extra: es lo que hace posible la interacción.
Cuando el usuario mueve el cursor sobre el gráfico, tienes una coordenada de píxel y necesitas saber a qué valor corresponde. Sin inversa, la única forma es recorrer todos los puntos calculando distancias, que es O(n) por movimiento del ratón. Con inversa, conviertes el píxel a valor y buscas el dato más cercano con una búsqueda binaria sobre el array ordenado, que es O(log n).
function masCercano(datos, valorX) {
let lo = 0, hi = datos.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (datos[mid].x < valorX) lo = mid + 1; else hi = mid;
}
const a = datos[Math.max(0, lo - 1)], b = datos[lo];
return Math.abs(a.x - valorX) <= Math.abs(b.x - valorX) ? a : b;
}
lienzo.addEventListener('pointermove', (e) => {
const caja = lienzo.getBoundingClientRect();
const valorX = x.invert(e.clientX - caja.left - margen.izda);
const punto = masCercano(datos, valorX);
mostrarTooltip(punto);
});
Con diez mil puntos, la diferencia entre las dos versiones es la diferencia entre un tooltip instantáneo y uno que va a tirones.
Y hay un segundo uso: el zoom por selección. El usuario arrastra un rectángulo, y las dos coordenadas se convierten en el nuevo dominio con invert. Sin inversa hay que reimplementar la conversión al revés en otro sitio, y las dos versiones derivan.
clamp y nice
Dos comportamientos que casi siempre quieres y que hay que pedir explícitamente.
clamp (recortar) impide que un valor fuera del dominio produzca una coordenada fuera del rango. Sin él, un dato atípico que supere el máximo se dibuja fuera del área y se cuela sobre el título del gráfico. Con él, se queda pegado al borde.
Cuándo no quieres recortar: cuando el desbordamiento es información. Si un valor se sale de la escala, verlo salirse comunica algo. En ese caso, en lugar de recortar, la solución es indicar el desbordamiento con un marcador en el borde.
nice extiende el dominio hasta los valores «redondos» más cercanos. Un dominio calculado de los datos como [3.7, 247.2] produce un eje con marcas feas; extendido a [0, 250], produce marcas cada 50. Es el mismo problema que resuelve el algoritmo de marcas bonitas, y se implementa con él:
escala.nice = (contador = 10) => {
const paso = incrementoDeMarca(d0, d1, contador);
d0 = Math.floor(d0 / paso) * paso;
d1 = Math.ceil(d1 / paso) * paso;
return escala;
};
La función incrementoDeMarca es la del artículo sobre marcas bonitas.
Math.min y Math.max sobre los datos parecen la elección obvia para el dominio, y encierran tres decisiones que casi nunca se toman conscientemente.
Primera: ¿el mínimo es el mínimo de los datos o es cero? Para un gráfico de barras, tiene que ser cero, porque la barra codifica el valor con su longitud y una base distinta de cero miente sobre las proporciones. Para un gráfico de líneas, no tiene por qué serlo, porque la línea codifica con la posición y la pendiente. Esta distinción es la que se trata con rigor en el nivel 29, y decide qué escribes en esta línea de código.
Segunda: ¿el dominio se recalcula en cada actualización? Si sí, un cambio de datos mueve el eje, y dos gráficos consecutivos no se pueden comparar visualmente porque la escala ha cambiado bajo los pies del usuario. Si no, un dato nuevo que supere el máximo se sale. La respuesta correcta depende del uso, y la peor es no haberla pensado: un dominio que cambia solo produce un panel donde las barras suben y bajan sin relación con los datos.
Tercera: ¿todas las series comparten dominio? En un gráfico de pequeños múltiplos, compartir el dominio permite comparar entre paneles y hace que las series pequeñas se vean planas. No compartirlo hace visible cada serie y hace la comparación imposible. Las dos opciones son legítimas y hay que elegir a propósito, no por el orden en que se escribió el bucle.
Y una comprobación que evita el fallo más tonto: un dominio degenerado. Si todos los valores son iguales, min y max coinciden, la escala divide por cero y todo el gráfico se convierte en NaN. El código de arriba lo trata devolviendo el punto medio del rango. Es un caso que llega siempre: el día que un usuario filtra hasta dejar un solo dato.
El olor del gráfico sin escalas
Cómo reconocer un gráfico que necesita esta refactorización: la fórmula de conversión aparece más de una vez.
// Olor
const y1 = alto - (d.valor / max) * alto;
// ...cincuenta lineas mas abajo
const yEtiqueta = alto - (d.valor / max) * alto - 6;
// ...y en el manejador del raton
const valor = ((alto - e.offsetY) / alto) * max;
Tres copias de la misma relación, una de ellas invertida a mano. Cuando el requisito cambie (añadir un margen, cambiar a escala logarítmica, invertir el eje), habrá que encontrar las tres. La tercera, que está en otro fichero, no se encontrará.
Con una escala, las tres son y(d.valor), y(d.valor) - 6 y y.invert(e.offsetY).
Refactoriza un gráfico existente extrayendo las escalas a objetos, y escribe pruebas unitarias que verifiquen tres cosas: que los extremos del dominio mapean a los extremos del rango, que invert es la inversa exacta en cinco valores intermedios, y que un dominio degenerado no produce NaN. Las tres pruebas juntas tardan un minuto en escribirse y detectan la mayoría de los fallos de escala.