Saltar al contenido

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.

Las siete capas

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:

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

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

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