Escalas de tiempo, ordinal y de banda
El tiempo y sus unidades desiguales, la escala ordinal para colores, y la fórmula exacta de la escala de banda con sus dos rellenos, que es la que coloca las barras.
Las tres escalas de este artículo son las que resuelven los casos que la lineal no cubre: un eje temporal cuyas unidades no son uniformes, un mapeo de categorías a colores, y el reparto de un ancho entre n barras con sus separaciones. La tercera tiene una fórmula concreta que conviene conocer, porque es la que decide dónde va cada barra de todos los gráficos de barras que escribas.
- Construir una escala de tiempo y elegir sus intervalos de marca.
- Explicar por qué las unidades de tiempo no son uniformes y qué implica.
- Mapear categorías a valores discretos con una escala ordinal.
- Aplicar la fórmula de la escala de banda con
paddingInnerypaddingOuter.
La escala de tiempo
Una escala de tiempo es, por dentro, una escala lineal sobre marcas de tiempo en milisegundos. Su diferencia está en las marcas y en el formato.
function escalaTiempo({ dominio: [d0, d1], rango: [r0, r1] }) {
const t0 = +d0, t1 = +d1;
const escala = (fecha) => r0 + ((+fecha - t0) / (t1 - t0)) * (r1 - r0);
escala.invert = (y) => new Date(t0 + ((y - r0) / (r1 - r0)) * (t1 - t0));
return escala;
}
Lo interesante son las marcas. Un eje temporal no puede usar el algoritmo de números redondos, porque los números redondos del tiempo no son potencias de diez: son segundos, minutos, horas, días, semanas, meses y años. Y esas unidades no son uniformes:
- Un mes tiene entre 28 y 31 días.
- Un año tiene 365 o 366 días.
- Un día tiene 23, 24 o 25 horas en los países con cambio de hora.
- Un minuto puede tener 61 segundos en un salto de segundo intercalar.
Eso significa que no se pueden generar marcas mensuales sumando 30 días. Hay que avanzar con aritmética de calendario:
const INTERVALOS = [
{ ms: 1000, nombre: 'segundo', paso: (d, n) => d.setSeconds(d.getSeconds() + n) },
{ ms: 60e3, nombre: 'minuto', paso: (d, n) => d.setMinutes(d.getMinutes() + n) },
{ ms: 3600e3, nombre: 'hora', paso: (d, n) => d.setHours(d.getHours() + n) },
{ ms: 864e5, nombre: 'dia', paso: (d, n) => d.setDate(d.getDate() + n) },
{ ms: 6048e5, nombre: 'semana', paso: (d, n) => d.setDate(d.getDate() + 7 * n) },
{ ms: 2629746e3, nombre: 'mes', paso: (d, n) => d.setMonth(d.getMonth() + n) },
{ ms: 31556952e3, nombre: 'anio', paso: (d, n) => d.setFullYear(d.getFullYear() + n) },
];
function marcasTiempo(d0, d1, objetivo = 8) {
const span = +d1 - +d0;
const idealMs = span / objetivo;
const iv = INTERVALOS.reduce((a, b) =>
Math.abs(b.ms - idealMs) < Math.abs(a.ms - idealMs) ? b : a);
const n = Math.max(1, Math.round(idealMs / iv.ms));
const marcas = [];
const cur = new Date(d0);
// Alinear al comienzo de la unidad
if (iv.nombre !== 'segundo') cur.setMilliseconds(0);
if (['hora','dia','semana','mes','anio'].includes(iv.nombre)) { cur.setSeconds(0); cur.setMinutes(0); }
if (['dia','semana','mes','anio'].includes(iv.nombre)) cur.setHours(0);
if (['mes','anio'].includes(iv.nombre)) cur.setDate(1);
if (iv.nombre === 'anio') cur.setMonth(0);
while (+cur <= +d1) {
if (+cur >= +d0) marcas.push(new Date(cur));
iv.paso(cur, n);
}
return { marcas, unidad: iv.nombre };
}
Los métodos setMonth y setFullYear hacen la aritmética de calendario correcta, incluidos los meses de distinta longitud y los años bisiestos. Sumar milisegundos, no.
El paso de alineación es lo que hace que las marcas caigan en el comienzo de la unidad: los meses el día 1, los días a medianoche. Sin él, un eje de tres meses muestra marcas el 7 de marzo, el 6 de abril y el 6 de mayo, lo que es correcto y se lee fatal.
El formato depende de la unidad. Un eje de horas etiqueta con la hora; uno de meses, con el nombre del mes; uno de años, con el año. Y hay una convención que mejora mucho la lectura: etiquetar con más contexto en el punto donde cambia la unidad superior.
const fmt = new Intl.DateTimeFormat('es-ES', { month: 'short' });
const fmtAnio = new Intl.DateTimeFormat('es-ES', { month: 'short', year: 'numeric' });
function etiquetar(fecha, anterior) {
const cambiaAnio = !anterior || fecha.getFullYear() !== anterior.getFullYear();
return (cambiaAnio ? fmtAnio : fmt).format(fecha);
}
Así el eje dice «oct, nov, dic, ene 2026, feb, mar» en lugar de repetir el año en cada marca o no ponerlo nunca.
Un apunte sobre zonas horarias: Date opera en la zona local del navegador. Si tus datos son marcas de tiempo en UTC y los usuarios están en zonas distintas, el mismo dato produce ejes distintos. Decide explícitamente en qué zona se representa y usa Intl.DateTimeFormat con timeZone para fijarla.
La escala ordinal
Mapea un dominio discreto a un rango discreto. Su uso principal es el color por categoría.
function escalaOrdinal({ dominio = [], rango = [] }) {
const mapa = new Map();
let desconocidos = 0;
const escala = (x) => {
const k = String(x);
if (!mapa.has(k)) {
const i = mapa.size < dominio.length
? dominio.indexOf(x)
: desconocidos++;
mapa.set(k, rango[(i >= 0 ? i : mapa.size) % rango.length]);
}
return mapa.get(k);
};
escala.dominio = () => [...mapa.keys()];
return escala;
}
const color = escalaOrdinal({
dominio: ['Norte', 'Sur', 'Este', 'Oeste'],
rango: ['#89b4fa', '#a6e3a1', '#f9e2af', '#f38ba8'],
});
color('Sur'); // #a6e3a1
El detalle que importa: el dominio tiene que ser explícito y estable. Si dejas que se construya por orden de aparición, un filtro que cambie el orden de los datos cambia los colores de las series, y el usuario ve que «el azul» pasa a ser otra región. Eso es peor que un color feo: es una inconsistencia que hace desconfiar del gráfico.
Regla: el mapeo de categoría a color se decide una vez para el conjunto completo de categorías posibles y no se recalcula.
La escala de banda
La que coloca las barras. Toma un dominio discreto de n categorías y un rango continuo, y devuelve la posición inicial de cada banda, más un ancho común.
Su comportamiento se controla con dos rellenos:
paddingInner: la fracción del paso que se deja como hueco entre bandas.paddingOuter: la fracción del paso que se deja antes de la primera y después de la última.
La fórmula exacta:
function escalaBanda({ dominio, rango: [r0, r1],
paddingInner = 0.1, paddingOuter = 0.1, align = 0.5 }) {
const n = dominio.length;
const total = r1 - r0;
const paso = total / Math.max(1, n - paddingInner + paddingOuter * 2);
const ancho = paso * (1 - paddingInner);
const inicio = r0 + (total - (paso * n - paso * paddingInner)) * align;
const indice = new Map(dominio.map((d, i) => [String(d), i]));
const escala = (x) => {
const i = indice.get(String(x));
return i === undefined ? undefined : inicio + i * paso;
};
escala.ancho = () => ancho;
escala.paso = () => paso;
escala.centro = (x) => {
const p = escala(x);
return p === undefined ? undefined : p + ancho / 2;
};
return escala;
}
El denominador n - paddingInner + 2 * paddingOuter es la parte que hay que entender: hay n pasos, pero el último no necesita su hueco interior (no hay banda después), y hay dos huecos exteriores. Esa aritmética es la que hace que las bandas queden repartidas de forma uniforme y simétrica.
Uso:
const x = escalaBanda({
dominio: ['Ene', 'Feb', 'Mar', 'Abr'],
rango: [0, ancho],
paddingInner: 0.2,
});
barras.forEach(b => {
rect.setAttribute('x', x(b.mes));
rect.setAttribute('width', x.ancho());
});
// Y la etiqueta del eje, centrada bajo la barra
texto.setAttribute('x', x.centro(b.mes));
Ese centro es el que casi todo el mundo calcula a mano y se equivoca. Tenerlo en la escala evita el error.
La escala de punto es el caso límite con ancho cero: sirve para colocar los puntos de un gráfico de dispersión categórico o los vértices de una línea con eje categórico. Se obtiene con paddingInner = 1, o directamente:
const paso = total / Math.max(1, n - 1 + 2 * paddingOuter);
const posicion = (i) => r0 + paso * (paddingOuter + i);
El valor de paddingInner tiene efectos que van más allá del aspecto y que conviene conocer.
Con relleno cero, las barras se tocan y el gráfico se convierte en un histograma. Y eso comunica algo distinto: un histograma representa una variable continua dividida en intervalos, y las barras se tocan porque los intervalos son contiguos. Un gráfico de barras representa categorías discretas y las barras se separan porque las categorías no son contiguas.
Usar barras pegadas para categorías discretas es un error de comunicación: sugiere una continuidad que no existe. Y separar las barras de un histograma sugiere huecos en los datos que tampoco existen. La convención es real y los lectores la interpretan, aunque no sepan que la conocen.
Con relleno muy alto, las barras se vuelven líneas y el canal cambia. Una barra de dos píxeles de ancho ya no se percibe como un área cuya longitud codifica el valor, sino como una marca posicional. No está mal, pero es otro gráfico, con otras propiedades perceptuales, y conviene saberlo.
El rango sensato para categorías discretas está entre 0,1 y 0,3. Por debajo de 0,05 parece un histograma; por encima de 0,5 las barras son demasiado finas para leer su longitud con comodidad.
Y un caso donde el relleno decide la legibilidad de verdad: las barras agrupadas. Con dos escalas de banda anidadas (una para el grupo y otra para las series dentro del grupo), el relleno interior de la exterior tiene que ser claramente mayor que el de la interior, o el ojo no distingue dónde acaba un grupo y empieza el siguiente. Una relación de tres a uno funciona: 0,3 fuera y 0,1 dentro. Con los dos iguales, el gráfico se lee como una sola serie larga y la agrupación se pierde por completo.
Implementa barras agrupadas con dos escalas de banda anidadas y prueba tres combinaciones de rellenos: iguales, con el exterior mayor, y con el interior mayor. Enséñaselas a alguien y pregúntale cuántos grupos ve. La respuesta cambiará entre las tres versiones con exactamente los mismos datos.