Reemplazar o Quedarse: Cómo DataLens Migró de Highcharts

Hola, me llamo Evgeny Alayev, soy desarrollador de interfaces en el equipo de Yandex DataLens. Es una herramienta BI en la nube para el análisis de datos y la construcción de dashboards, y los gráficos en ella no son “una funcionalidad más”, sino el corazón del producto. El usuario abre un dashboard y lo primero que ve son las visualizaciones. Son ellas las que responden a la pregunta: “¿Qué está pasando con mis datos?”

DataLens funciona en dos instalaciones: para el propio Yandex y para usuarios externos. En total, hasta hoy se han creado más de 18,3 millones de gráficos. Cada uno de esos gráficos es el resultado del trabajo de esa misma biblioteca de visualización de la que vamos a hablar.

Durante mucho tiempo, los gráficos en DataLens se construían sobre Highcharts. Al principio fue una elección razonable: inicio rápido, amplio conjunto de tipos, gran comunidad. Pero una herramienta BI se vuelve más compleja con el tiempo: aparecen requisitos no estándar de comportamiento, un sistema de diseño que hay que mantener en un estilo coherente. Y en algún momento Highcharts empezó a estorbar más que a ayudar.

En este artículo contaré cómo y por qué tomamos la decisión de escribir nuestra propia biblioteca de visualización de código abierto: @gravity‑ui/charts. Mi colega y yo somos core contributors de esta biblioteca, por lo que explicaré en detalle qué nos disgustaba de Highcharts, qué alternativas consideramos, cómo está estructurada la arquitectura y a qué desafíos técnicos concretos nos enfrentamos en el proceso.


De la comodidad a las limitaciones

Licencia y vendor lock

Highcharts es una biblioteca comercial. Para uso no comercial es gratuita, pero en cuanto el producto se monetiza se necesita una licencia. Eso en sí mismo no es un problema: muchos equipos trabajan con herramientas de pago. El problema comienza cuando el modelo de uso se vuelve más complejo.

DataLens tiene tres formatos de distribución: nube, código abierto y on‑premises. Una licencia no libre en las dependencias, aunque sea opcional, complica la entrega de cada uno de ellos.

Además de la licencia, existe una dependencia rígida del roadmap ajeno. ¿Quieres un nuevo tipo de gráfico? Espera a que lo haga el proveedor. ¿Encontraste un bug en el comportamiento del tooltip? Abre un ticket y espera que entre en las prioridades. ¿Necesitas un comportamiento no estándar al hacer clic en la leyenda? Busca un workaround en la documentación o en Stack Overflow. Cuando el producto evoluciona activamente y los requisitos de visualización no son triviales, esa dependencia empieza a frenar.

Hay también otro problema: las actualizaciones de versión mayor. Usábamos la última versión de Highcharts dentro del octavo mayor. La transición al siguiente mayor ya era costosa de por sí, pero lo que la hacía especialmente complicada era una particularidad de DataLens: tenemos el Editor, un modo en el que los usuarios escriben código JavaScript para configurar el gráfico. En la práctica, trabajan directamente con Highcharts, formando la configuración mediante código. Ese código de usuario no se puede migrar automáticamente: no se puede escribir un codemod que traduzca de forma segura JavaScript arbitrario de una interfaz a otra.

Control sobre el comportamiento

En una herramienta BI los usuarios interactúan activamente con los gráficos: hacen clic en la leyenda para ocultar una serie, se detienen sobre los puntos de datos, fijan tooltips. Cada uno de esos escenarios en Highcharts requería una configuración precisa mediante callbacks y eventos, que no siempre estaban documentados ni siempre eran estables entre versiones.

Aquí algunos ejemplos representativos:

  • Fijación del tooltip. El tooltip estándar de Highcharts desaparece al mover el ratón. La compatibilidad nativa con el tooltip “adherente” no existía en el octavo mayor: apareció solo en la versión 12. Lo implementamos mediante redefinición de métodos internos: interceptábamos eventos del ratón y sustituíamos la lógica de ocultación. El código funcionaba, pero era frágil.

  • Renderizado personalizado del tooltip. El formatter devuelve una cadena HTML, lo que implica ensamblar el marcado manualmente mediante concatenación. Sin React, sin componentes. Al final, el renderizado del tooltip se convirtió en una capa separada con su propia lógica de plantillas.

  • Clic en la leyenda. El comportamiento estándar alterna la visibilidad de la serie. Redefinirlo completamente sin romper lo demás no es trivial: el manejador había que adjuntarlo mediante eventos internos y cancelar con cuidado el comportamiento predeterminado.

  • Tematización. Los colores, fuentes y márgenes en Highcharts se definen a través de la configuración JavaScript y se aplican como estilos inline. Por encima de esto manteníamos un archivo SCSS separado para sobrescribir los estilos base, que había que sincronizar manualmente con cada actualización de la paleta en Gravity UI.

Cada uno de esos rodeos no es un problema aislado, sino una contribución a la deuda técnica global. Un hack vive por sí solo, dos se pueden mantener en la cabeza, pero al décimo empiezan a interactuar: arreglas uno y se rompe otro. La complejidad de mantenimiento crece de forma no lineal, y cada actualización de Highcharts se convierte en una auditoría: qué parte de nuestros rodeos se ha roto silenciosamente esta vez.

Evaluación de alternativas

Antes de escribir algo propio, repasamos honestamente las soluciones existentes. Los criterios principales fueron:

  • Licencia abierta: MIT o compatible, sin niveles de pago ni acuerdos OEM.

  • Personalización completa del renderizado y los estilos: posibilidad de controlar el aspecto de cualquier elemento sin rodeos.

  • Integración de HTML: el tooltip, las etiquetas del gráfico y las etiquetas de los ejes deben renderizarse como marcado HTML completo, no como texto SVG ni cadenas de plantilla.

Analizamos ECharts, Recharts, Plotly, Chart.js y D3.

Chart.js y Plotly quedaron descartados rápidamente: el renderizado en Canvas por defecto cierra las posibilidades de personalización de estilos mediante CSS, y la integración de HTML arbitrario en los elementos del gráfico no está contemplada.

Recharts: React‑first, SVG, MIT. Encaja bien para dashboards sencillos, pero la personalización profunda de formas y comportamiento choca rápidamente con las limitaciones de la API interna.

ECharts era la opción más cercana: amplio conjunto de tipos, configuración flexible, soporte de renderizadores personalizados. Pero al examinarlo más de cerca no cubría uno de los criterios clave: la integración completa de HTML en partes arbitrarias del gráfico. Para nuestros casos de uso eso era fundamental. Además, el vendor lock no desaparecía: la licencia MIT resolvía la cuestión legal, pero la dependencia del roadmap ajeno y el ciclo de actualizaciones permanecía.

D3 no es una biblioteca de gráficos, sino un conjunto de primitivas: escalas, generadores de formas, transformaciones de datos. Por sí solo no resuelve la tarea, pero ofrece la base ideal para construir una solución propia encima.

La conclusión fue lógica: tomar D3 como base y construir encima nuestra propia biblioteca. Eso nos daba libertad, control y eliminaba por completo la dependencia de cualquier proveedor.

Por qué D3 y no otra cosa

D3 no dibuja gráficos. Es un conjunto de herramientas para trabajar con datos y el DOM: escalas, generadores de formas geométricas, transformaciones. Eso es exactamente lo que necesitábamos: primitivas con las que poder construir cualquier visualización sin estar limitados por lo que el autor de la biblioteca haya previsto.

D3 proporciona scaleLinear, scaleBand, scaleUtc para los ejes, d3.line (), d3.arc (), d3.area () para las formas, d3.extent () y d3.group () para las transformaciones de datos. Todo lo demás es nuestro.

Arquitectura de @gravity‑ui/charts

Punto de entrada: el objeto de configuración

El usuario pasa al componente un único objeto con los datos y la configuración: series, ejes, título, leyenda, tooltip. Es una elección deliberada a favor del enfoque declarativo: la mayor parte del config se serializa fácilmente, se puede registrar y transmitir entre sistemas.

Allí donde la descripción declarativa no es suficiente, el config acepta funciones: un renderizador personalizado de tooltip, manejadores de clic, formateadores de etiquetas de eje. Esto no es una contradicción, sino un límite intencional: la estructura de datos permanece predecible y los puntos de extensión son explícitos.

Flujo de datos

Internamente, el config pasa por varias etapas de procesamiento:

  • Normalización. Los datos de entrada se llevan a un formato interno unificado. En esta etapa se establecen los valores predeterminados, se resuelven las ambigüedades y los datos de cada serie se tipifican.

  • Preparación de series. Cada tipo de gráfico (línea, barra, tarta, etc.) es procesado por su propio módulo. Se asignan colores y se generan los metadatos para la leyenda.

  • Construcción de escalas y ejes. A partir de los datos, D3 construye escalas: lineales, logarítmicas, temporales, categóricas. Las escalas determinan cómo se traducen los valores de los datos a coordenadas en píxeles.
  • Renderizado de formas. Cada tipo de serie dibuja sus elementos SVG: líneas, rectángulos, arcos, puntos. D3 proporciona los generadores de formas y React gestiona el ciclo de vida de los elementos.

SVG + HTML: renderizado híbrido

El renderizado principal es SVG. Esto ofrece límites nítidos, escalado sin pérdida de calidad y control total sobre el posicionamiento.

Pero SVG no puede ajustar texto, ni admite marcado HTML enriquecido dentro de los elementos. Para las etiquetas de datos con saltos de línea, tooltips personalizados y elementos interactivos sobre el gráfico se usa una capa HTML separada: un div posicionado absolutamente que se superpone al SVG y se sincroniza con sus coordenadas.

Sistema de eventos

Las interacciones del usuario con el gráfico —hover, clic, movimiento del ratón— se gestionan a través de un bus de eventos centralizado basado en d3.dispatch. Esto permite que distintas partes de la interfaz (tooltip, crosshair, leyenda) reaccionen a un mismo evento de forma independiente y coherente, sin dependencias directas entre componentes.

Pero este enfoque tiene otra consecuencia importante: el rendimiento. El movimiento del ratón sobre el gráfico genera eventos con alta frecuencia. Si en cada uno de esos eventos se lanzara un renderizado de React, conllevaría costosos recálculos de todo el árbol de componentes. En su lugar, parte de las actualizaciones —por ejemplo, el hover de las formas y la búsqueda del punto más cercano al puntero— se aplican directamente a través de D3, sin pasar por React. El componente no se vuelve a renderizar: D3 simplemente actualiza los atributos DOM necesarios. React entra en acción solo donde es realmente necesario: cuando cambia la estructura o el estado que afecta a todo el grafo.

Migración sin detener el producto

El camino de los datos desde la fuente hasta el gráfico

Antes de hablar sobre el cambio de biblioteca, es importante entender cómo está organizado el renderizado de gráficos en DataLens, porque precisamente esa arquitectura determinó cómo podíamos migrar.

Cuando el usuario abre un chart (o ese chart se renderiza en un dashboard), se envía una petición a nuestro backend Node.js para obtener los datos. El backend Node.js, a su vez, consulta nuestro servicio Python para obtener los datos en bruto. Pero los datos por sí solos no son aún un gráfico. En Node ocurre la preparación: se determina el tipo de visualización, se comprueba si se superan los límites de datos para ese tipo y, si todo está en orden, se selecciona la función de preparación de datos específica para el tipo de gráfico concreto.

El punto clave: cada tipo de visualización tiene su propia función de preparación de datos. Para el gráfico lineal una, para el de barras otra, para el de área una tercera. El resultado de esa función es el config que se envía al cliente. Allí no llega directamente a @gravity‑ui/charts, sino a @gravity‑ui/chartkit: un paquete separado que usamos para trabajar con varias bibliotecas de visualización simultáneamente. Carga dinámicamente solo la biblioteca necesaria para el tipo de chart concreto y proporciona para todas ellas una interfaz unificada: el código del cliente no sabe ni le importa qué biblioteca renderiza cada tipo concreto.

Además del enrutamiento, chartkit contiene capas adicionales sobre las propias bibliotecas: lógica que no cabe en el marco de una biblioteca concreta, pero que se necesita en DataLens. Por ejemplo, mostrar el tooltip al tocar en un dispositivo móvil es un comportamiento que se necesita por igual para Highcharts y para @gravity‑ui/charts, por lo que está implementado a nivel de chartkit.

Esas cosas son potencialmente útiles no solo en DataLens. Así que un relato detallado sobre @gravity‑ui/chartkit merece un artículo aparte.

Estrategia de transición gradual

Esta arquitectura nos permitió migrar por etapas, sin reescribirlo todo de una vez.

Introdujimos feature flags a nivel del tipo de visualización. La lógica es simple: si el flag para una visualización concreta está en true, Node prepara los datos en formato @gravity‑ui/charts y el cliente usa la nueva biblioteca. Si el flag no está activo, los datos se preparan en formato Highcharts y se renderiza el código antiguo.

Esto significaba que en algún momento ambas bibliotecas funcionaban simultáneamente en DataLens, cada una para su conjunto de tipos de gráficos. Este enfoque ofreció varias ventajas:

  • Riesgo aislado. Un bug en la nueva implementación del gráfico de barras horizontales no afecta a los gráficos de barras verticales que aún están en Highcharts.

  • Verificación gradual. Cada tipo, tras la transición, pasa un período de observación en producción antes de que migre el siguiente.

  • Posibilidad de rollback. Si algo salió mal, basta con desactivar el flag.

La migración se realizó en tres oleadas, y el orden fue elegido conscientemente: de lo simple a lo complejo.

Oleada 1: pie y treemap. El inicio más lógico: visualizaciones sin ejes. Sin escalas, sin crosshair, sin sistema de coordenadas complejo. Esto permitió depurar la integración básica, configurar el pipeline de preparación de datos y verificar que la infraestructura de transición funcionaba correctamente, sin arriesgar los tipos más utilizados.

Oleada 2: bar‑y, bar‑y normalizado, scatter. El siguiente paso: gráficos con ejes, pero con una limitación importante: estos tipos no se usan en modo split (cuando varios gráficos se apilan uno bajo otro con un eje X común) ni participan en diagramas combinados. Eso reducía drásticamente el número de casos extremos y hacía la transición predecible.

Oleada 3: area, area normalizado, bar‑x, bar‑x normalizado, line y sus combinaciones. La etapa más compleja. Precisamente estos tipos son los más populares en DataLens: se usan en la mayoría de los dashboards.

Además, aquí aparecen los diagramas combinados (por ejemplo, line + bar‑x en un mismo gráfico), el modo split y todos los escenarios de interacción no triviales. Cada una de esas visualizaciones requería atención especial durante las pruebas, y los feature flags daban la posibilidad de hacer rollback si algo salía mal en producción.

Soluciones técnicas

Objeto de configuración: familiar, pero mejor. Elegimos conscientemente el objeto de configuración como forma de describir el gráfico. DataLens ya trabajaba con ese enfoque, y un cambio brusco de paradigma habría creado una barrera innecesaria. La estructura del config se asemeja en gran medida a lo que los desarrolladores conocen: series, ejes, título, tooltip, todo en su sitio. Pero donde la interfaz de Highcharts nos parecía deficiente, tomamos nuestras propias decisiones. No por originalidad, sino porque identificamos problemas o inconvenientes concretos en el uso real.

Ejemplo mínimo: gráfico lineal con eje temporal:

import {Chart} from '@gravity-ui/charts';

<Chart
    data={{
        series: {
            data: [
                {
                    type: 'line',
                    name: 'Ingresos',
                    data: [
                        {x: new Date('2024-01-01').getTime(), y: 120},
                        {x: new Date('2024-02-01').getTime(), y: 145},
                        {x: new Date('2024-03-01').getTime(), y: 132},
                        {x: new Date('2024-04-01').getTime(), y: 178},
                    ],
                },
            ],
        },
        xAxis: {type: 'datetime'},
        yAxis: [{title: {text: 'miles de unid.'}}],
    }}
/>

El resultado es:

Pero también se puede hacer un gráfico lineal más complejo:

Valores predeterminados razonables y personalización donde se necesita. Años de desarrollo de una herramienta BI nos han dado una comprensión clara de cómo debe comportarse una biblioteca de gráficos en ese tipo de producto. Los escenarios típicos deben funcionar de fábrica, sin configuración y sin sorpresas. Donde los valores predeterminados no son suficientes, existen puntos de extensión explícitos: pasar un componente React propio como renderizador de tooltip, redefinir el formateador de etiquetas de eje, establecer el grosor de las líneas. Todo ello a través del mismo objeto de configuración, sin necesidad de entrar en las entrañas de la biblioteca.

Integración nativa con Gravity UI. DataLens está construido sobre Gravity UI, un sistema de diseño con componentes, iconos y tematización CSS. La tematización funciona a través de variables CSS de @gravity‑ui/uikit: basta con activar el tema y todos los colores de los ejes, cuadrículas y etiquetas se adaptan automáticamente al modo claro u oscuro. El tooltip y los botones en la interfaz del gráfico son componentes estándar de uikit que heredan la accesibilidad, la navegación por teclado y el manejo de eventos del sistema de diseño.

Escala sin caos. Cuando hay muchos tipos de gráficos, la arquitectura empieza a determinar el resultado. Añadir un nuevo tipo no debe requerir modificaciones en el núcleo ni romper los que ya funcionan. Lo resolvimos mediante una estructura uniforme para cada tipo: su propia preparación de datos, su propio componente de renderizado, sus propios tipos, todo en un módulo aislado. El núcleo de la biblioteca conoce la existencia de los tipos solo a través de un contrato común, no a través de implementaciones concretas.

Pruebas visuales. La visualización es, ante todo, lo que ve el usuario, por lo que los tests unitarios no son suficientes. Cada tipo de gráfico está cubierto por tests de captura de pantalla en Playwright, que se ejecutan en Docker para garantizar la reproducibilidad. Un cambio en un módulo no puede romper silenciosamente el renderizado de otro: eso es inmediatamente visible gracias al snapshot fallido.

Resultados y conclusiones

La transición llevó tiempo y requirió inversiones importantes. Pero el resultado no es simplemente “reemplazamos una biblioteca por otra”. Esto es lo que obtuvimos:

  • Control total sobre el código. Cuando algo no funciona bien, podemos ir a cualquier parte, entender la causa y arreglarlo. Sin cajas negras, sin workarounds sobre código ajeno, sin esperar a que el proveedor publique un fix.

  • Código abierto. La biblioteca está disponible para todos, no solo para el equipo de DataLens. Eso significa contribuciones externas, issues públicos e historial de cambios transparente. Un problema reportado por un usuario externo ayuda a mejorar el producto para todos.

  • Lenguaje visual unificado. Los gráficos se convirtieron en una parte orgánica de la interfaz de DataLens, no en un elemento insertado de un proveedor externo. Tematización, tipografía, componentes interactivos: todo desde un mismo sistema de diseño.

Escribir una biblioteca de gráficos propia no es una decisión que deba tomarse a la ligera. Supone un gran volumen de trabajo, la necesidad de mantener, documentar y desarrollar un producto adicional.

Pero si tu herramienta se construye en torno a la visualización de datos, si tienes requisitos no estándar de comportamiento, si quieres control total sobre el aspecto visual y una integración profunda con el sistema de diseño, el vendor lock sobre una biblioteca ajena se convertirá en algún momento en un techo. Nosotros chocamos con ese techo y decidimos eliminarlo.

Qué sigue

La biblioteca está en activo desarrollo y tenemos varias líneas de trabajo en curso ahora mismo.

Núcleo framework‑agnostic. Hoy @gravity‑ui/charts es una biblioteca React. Planeamos extraer el núcleo —la lógica de preparación de datos, construcción de escalas, cálculo de coordenadas— en un paquete separado sin dependencia de ningún framework. Esto abrirá dos caminos: uso en JavaScript puro sin React, y la posibilidad de escribir wrappers ligeros para Vue, Angular u otros frameworks sin duplicar la lógica principal.

Range slider para ejes categóricos. El componente range slider funciona actualmente con ejes numéricos y temporales: permite seleccionar un rango y “acercar” la parte deseada de los datos. Pero los ejes categóricos (por ejemplo, una lista de países o nombres) son igualmente importantes para el análisis BI. Estamos mejorando el slider para que pueda trabajar también con categorías: seleccionar un subconjunto de valores y pasarlo al filtrado de datos.

Documentación para contribuidores. Actualmente, añadir un nuevo tipo de gráfico es un proceso comprensible para quienes conocen la arquitectura de la biblioteca desde dentro. Pero para un contribuidor externo no es tan obvio. Queremos crear guías detalladas: qué es un módulo de tipo, qué contrato debe cumplir, cómo escribir los tests, cómo conectar una nueva visualización al sistema general. El objetivo es que alguien que nunca haya trabajado con la base de código pueda añadir un nuevo tipo de gráfico de forma autónoma siguiendo la documentación.


La biblioteca @gravity‑ui/charts está publicada bajo licencia MIT: puedes probar a trabajar con ella en tu proyecto.

¡Nos alegrará que le des una estrella en GitHub! ⭐

Reemplazar o Quedarse: Cómo DataLens Migró de Highcharts

Sign in to save this post