La arquitectura de un sistema de visualización
Las siete capas de un gráfico de principio a fin, qué no debe saber cada una, la separación entre estado de datos y estado de vista, y qué se recalcula ante cada tipo de cambio.
Todo lo que has visto en treinta niveles se ordena en siete capas con fronteras concretas, y la calidad de esas fronteras decide si el sistema aguanta tres años o se reescribe cada seis meses. La pieza central no es el renderizador ni las escalas: es una función pura que convierte datos y dimensiones en una descripción geométrica, y todo lo demás se cuelga de ella.
- Situar las siete capas de un sistema de visualización y sus contratos.
- Definir qué no debe conocer cada capa y por qué.
- Separar el estado de datos del estado de vista.
- Determinar qué se recalcula ante un cambio de datos, de tamaño o de puntero.
Las siete capas
flowchart TB A[Datos de origen] --> B[Transformar y agregar] B --> C[Calcular dominios] C --> D[Construir escalas] D --> E[Especificacion geometrica pura] E --> F[Render SVG en cliente] E --> G[Render en lienzo] E --> H[Render a cadena en servidor] F --> I[Interaccion] G --> I I -->|zoom seleccion filtro| B H --> J[PNG y PDF para correo e informes] style A fill:#89b4fa,color:#11111b style B fill:#89b4fa,color:#11111b style C fill:#cba6f7,color:#11111b style D fill:#cba6f7,color:#11111b style E fill:#a6e3a1,color:#11111b style F fill:#94e2d5,color:#11111b style G fill:#fab387,color:#11111b style H fill:#94e2d5,color:#11111b style I fill:#f9e2af,color:#11111b style J fill:#a6e3a1,color:#11111b
Y el contrato de cada una, que es lo que de verdad importa:
Uno: los datos de origen. Lo que llega del servidor, sin tocar. Se guardan tal cual y no se mutan nunca. La regla: cualquier transformación produce un array nuevo, porque mutar el origen hace imposible deshacer un filtro sin volver a pedir los datos.
Dos: transformar y agregar. Filtrar, agrupar, ordenar, calcular medias móviles, recortar a una ventana temporal. Es la capa que decide qué se representa. No sabe nada de píxeles.
Tres: calcular dominios. El paso que el nivel 27 identificó como la decisión más consecuente del gráfico: si el mínimo es cero o el de los datos, si se recalcula en cada actualización, si las series comparten dominio. Es una capa aparte y no una línea perdida en la de escalas, precisamente porque es una decisión de producto y no de dibujo.
Cuatro: construir escalas. Dominios más rangos más tipo de escala. Depende de las dimensiones del área de dibujo, así que es la primera capa que sabe que existe una pantalla.
Cinco: la especificación geométrica. Una función pura de datos y dimensiones a una estructura con coordenadas ya calculadas. No sabe si va a acabar en SVG, en un lienzo o en un fichero. Es el corazón del sistema.
Seis: los renderizadores. Traducen la especificación a un medio y no toman decisiones. Si un renderizador calcula algo, ese algo pertenecía a la capa cinco.
Siete: la interacción. Traduce eventos del puntero y del teclado en cambios de estado, y esos cambios reentran por la capa dos.
La especificación como frontera
Todo el sistema se articula alrededor de una estructura de datos. Conviene verla escrita:
export function especificar(datos, { ancho, alto, opciones = {} }) {
const dominioY = opciones.desdeCero
? [0, Math.max(...datos.map(d => d.valor))]
: extension(datos, d => d.valor);
const y = escalaLineal({ dominio: redondearDominio(...dominioY, 5), rango: [alto, 0] });
const x = escalaBanda({ dominio: datos.map(d => d.etiqueta), rango: [0, ancho],
paddingInner: 0.2 });
return {
ancho, alto,
barras: datos.map(d => ({
clave: d.id,
x: x(d.etiqueta),
y: y(d.valor),
ancho: x.ancho(),
alto: alto - y(d.valor),
color: '#89b4fa',
etiqueta: d.etiqueta,
valor: d.valor,
})),
ejeY: marcas(...y.dominio(), 5).map(v => ({ y: y(v), texto: formatear(v) })),
ejeX: datos.map(d => ({ x: x.centro(d.etiqueta), texto: d.etiqueta })),
// Se exporta la inversa para el hit testing, no las escalas enteras
valorEnX: (px) => datos[Math.floor((px / ancho) * datos.length)],
};
}
Cuatro propiedades de esa estructura que la hacen la frontera correcta:
Es serializable. Números, cadenas y arrays. Se puede mandar a un worker, guardar en una prueba de instantánea, comparar con una igualdad profunda, y enviar por la red.
Contiene claves estables. Cada marca lleva su clave, que es lo que permite la constancia de objeto en cualquier renderizador, con join de D3, con el key de un framework o con un Map propio.
No contiene funciones de dibujo. No hay un pintar() dentro. Si lo hubiera, la capa cinco sabría de renderizado.
Expone la inversa, no las escalas. El renderizador y la capa de interacción necesitan convertir píxeles a datos, y no necesitan poder cambiar el dominio. Exponer lo mínimo evita que un manejador de eventos empiece a manipular escalas.
Y el argumento decisivo a favor de esta frontera: es la que hace posible todo lo que promete el nivel 30. El renderizador de SVG y el de lienzo consumen lo mismo, así que el híbrido es trivial. El renderizador de servidor consume lo mismo, así que la exportación no duplica cálculos. Y la prueba unitaria compara estructuras, no píxeles.
Estado de datos y estado de vista
El error de arquitectura más caro después de no tener capa cinco es meter todo el estado en una bolsa.
Estado de datos: qué datos hay, qué filtros están aplicados, qué agregación. Cambiarlo obliga a recorrer las siete capas.
Estado de vista: el tamaño del contenedor, el zoom, la serie destacada, el punto bajo el puntero, la selección. Cambiarlo obliga a recorrer un subconjunto, y a veces ninguna.
const estado = {
datos: { origen: [], filtro: null, agregacion: 'dia' },
vista: { ancho: 0, alto: 0, zoom: null, resaltado: null, seleccion: [] },
};
Esa separación no es organizativa: decide el rendimiento. Un movimiento del ratón cambia vista.resaltado, y si tu código reacciona recalculando la especificación entera, estás recalculando escalas sesenta veces por segundo para cambiar el color de una barra.
La tabla de qué se recalcula ante cada cambio:
| Cambia | Transformar | Dominios | Escalas | Especificación | Render |
|---|---|---|---|---|---|
| Los datos | Sí | Sí | Sí | Sí | Completo |
| Un filtro | Sí | Depende | Sí | Sí | Completo |
| El tamaño | No | No | Sí | Sí | Completo |
| El zoom | No | Sí | Sí | Sí | Completo |
| El resaltado | No | No | No | No | Solo la capa de encima |
| La selección | No | No | No | No | Solo la capa de encima |
| El tema | No | No | No | No | Completo, sin recalcular |
Las dos filas en negrita son las que más se incumplen y las que más se notan. Un resaltado no cambia ninguna geometría: solo cambia qué se pinta encima. En el enfoque híbrido eso significa repintar la capa de encima y no tocar la de datos, y es la diferencia entre un tooltip instantáneo y uno que va a tirones con cien mil puntos.
La fila del filtro tiene una casilla que dice «depende», y es una decisión de producto: si el dominio se recalcula al filtrar, el eje se mueve y el usuario pierde la referencia; si no, los datos filtrados pueden ocupar una franja diminuta. La respuesta correcta depende de si el usuario está comparando o explorando, y lo único inaceptable es no haberla decidido.
El reparto en ficheros
La estructura que se sostiene, con una responsabilidad por módulo:
grafico/
transformar.js capa 2, funciones puras sobre arrays
escalas.js capas 3 y 4, sin dependencias del DOM
especificar.js capa 5, la funcion central, pura
render-svg.js capa 6, importa especificar solo por el tipo
render-lienzo.js capa 6
render-cadena.js capa 6, corre en Node
interaccion.js capa 7, unico modulo que registra escuchadores
montar.js ciclo de vida, une todo, devuelve actualizar y destruir
Y una prueba de que el reparto es correcto, que se puede automatizar: ningún fichero salvo los tres renderizadores, interaccion.js y montar.js debe mencionar document, window o devicePixelRatio. Es una regla de un linter y detecta la fuga en el momento en que ocurre, que es cuando cuesta un minuto arreglarla.
La arquitectura de este artículo se degrada de una forma concreta y repetida, y merece la pena saber por dónde para poder defenderla.
Empieza así: necesitas saber si la etiqueta de una barra cabe dentro de ella o hay que sacarla fuera. Para saberlo hay que medir el texto, y medir texto necesita un contexto de dibujo. Así que en especificar.js aparece un document.createElement('canvas') y una llamada a measureText. La capa pura ha dejado de serlo, y con ella se va la ejecución en Node, la prueba sin navegador y el render en servidor.
El mismo agujero se abre por cuatro sitios más, todos legítimos: calcular el ancho del margen izquierdo a partir de la etiqueta más larga del eje, decidir cuántas marcas caben, romper un título en varias líneas, y decidir si rotar las etiquetas del eje horizontal. Los cinco son problemas de medida de texto, y los cinco son decisiones geométricas de pleno derecho que pertenecen a la capa cinco.
Hay tres salidas, y elegir mal cuesta caro:
Uno: inyectar el medidor. La función pura recibe una función medir(texto, fuente) como dependencia. En el navegador se le pasa una que usa measureText; en Node, una que estima a partir de una tabla de anchos o de una métrica de fuente. La capa sigue siendo pura porque no crea nada, solo llama a lo que le dan. Es la solución correcta y es la que usan los sistemas serios.
export function especificar(datos, { ancho, alto, medir }) {
const anchoMax = Math.max(...etiquetas.map(t => medir(t, '11px system-ui').ancho));
const margenIzda = Math.ceil(anchoMax) + 12;
// ...
}Dos: estimar. Un ancho medio por carácter multiplicado por la longitud. Es sorprendentemente suficiente para decidir márgenes y rotaciones, se equivoca en fuentes proporcionales con textos cortos, y no necesita nada. Para un margen izquierdo con un poco de holgura, la estimación gana a la medición exacta por simplicidad.
Tres: medir en el renderizador y devolver el resultado a la especificación. Es decir, dos pasadas: especificar con márgenes provisionales, renderizar, medir lo real, volver a especificar. Funciona, cuesta el doble, y es la fuente del parpadeo de los gráficos que se recolocan al cargar. Evítalo salvo que no haya alternativa.
Y el aviso que cierra el asunto: la medición de texto no da el mismo resultado en el navegador y en el servidor, porque depende de la fuente instalada, de la versión de la fuente y del motor de texto. Si generas el mismo gráfico por los dos caminos y mides en los dos, obtendrás márgenes ligeramente distintos y las dos imágenes no serán idénticas. Si necesitas que lo sean, la única opción es estimar con la misma tabla en los dos sitios, y aceptar que la estimación se equivoca un poco en los dos por igual. Un error idéntico en las dos rutas es preferible a dos aciertos distintos, y esa frase resume por qué la exactitud no siempre es el objetivo.
Coge tu gráfico más complejo y añade una regla de linter que prohíba document, window y devicePixelRatio en todos los ficheros salvo los renderizadores y el módulo de ciclo de vida. Cuenta las infracciones. Cada una es una decisión geométrica que se te ha colado en la capa equivocada, y la mayoría, si no todas, serán mediciones de texto.