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.
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'); // #a6e3a1El 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 barratexto.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);