Saltar al contenido

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.

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.

Texto
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

JS
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:

JS
const y = escalaLineal({ dominio: [0, 250], rango: [300, 0] });y(125);          // 150y(0);            // 300y(250);          // 0y.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:

JS
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).

JS
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:

JS
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.

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.

JS
// Olorconst y1 = alto - (d.valor / max) * alto;// ...cincuenta lineas mas abajoconst yEtiqueta = alto - (d.valor / max) * alto - 6;// ...y en el manejador del ratonconst 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).