Привет, меня зовут Евгений ��лаев, я разработчик интерфейсов в команде Yandex DataLens. Это облачный BI‑инструмент для анализа данных и построения дашбордов, и графики в нём — не «одна из фич», а сердце продукта. Пользователь открывает дашборд и первое, что видит, — визуализации. Именно они отвечают на вопрос: «Что происходит с моими данными?»

DataLens работает в двух инсталляциях — для самого Яндекса и для внешних пользователей. Суммарно на сегодня создано больше 18,3 млн графиков. Каждый из этих графиков — результат работы той самой библиотеки визуализации, о которой пойдёт речь.

Долгое время графики в DataLens строились на Highcharts. Поначалу это был разумный выбор: быстрый старт, богатый набор типов, большое сообщество. Но BI‑инструмент со временем становится сложнее — появляются нестандартные требования к поведению, дизайн‑система, которую нужно выдерживать в едином стиле. И в какой‑то момент Highcharts начал мешать больше, чем помогать.

В этой статье расскажу, как и почему мы приняли решение написать собственную опенсорс‑библиотеку для визуализации — @gravity‑ui/charts. Мы с коллегой — core‑контрибьютеры этой библиотеки, так что я в подробностях расскажу, что нас не устраивало в Highcharts, какие альтернативы рассматривали, как устроена архитектура и с какими конкретными техническими вызовами столкнулись в процессе.


От удобства к ограничениям

Лицензия и vendor lock

Highcharts — коммерческая библиотека. Для некоммерческого использования она бесплатна, но как только продукт монетизируется, нужна лицензия. Это само по себе не проблема — многие команды работают с платными инструментами. Проблема начинается там, где модель использования становится сложнее.

У DataLens три формата распространения: облако, опенсорс и on‑premises. Несвободная лицензия в зависимостях — даже опциональная — усложняет для нас поставку каждого из них.

Помимо лицензии — жёсткая зависимость от чужого roadmap. Хочешь новый тип графика? Жди, пока его сделает вендор. Нашёл баг в поведении тултипа? Открывай тикет и надейся, что он попадёт в приоритет. Нужно нестандартное поведение при клике на легенду? Ищи workaround в документации или на Stack Overflow. Когда продукт активно развивается и требования к визуализации нетривиальные, такая зависимость начинает тормозить.

Отдельная история — мажорные обновления. Мы использовали последнюю версию Highcharts в рамках восьмого мажора. Переход на следующий мажор выглядел дорого сам по себе, но особенно сложным его делала одна особенность DataLens: у нас есть Editor — режим, в котором пользователи пишут JavaScript‑код для настройки графика. Фактически они работают с Highcharts напрямую, формируя конфигурацию через код. Такой пользовательский код невозможно мигрировать автоматически: нельзя написать кодмод, который безопасно переведёт произвольный JS с одним интерфейсом на другой.

Контроль над поведением

В BI‑инструменте пользователи активно взаимодействуют с графиками: кликают на легенду, чтобы скрыть серию, зависают на точках данных, фиксируют тултипы. Каждый из этих сценариев в Highcharts требовал тонкой настройки через колбэки и события — причём не всегда задокументированные и не всегда стабильные между версиями.

Вот несколько характерных примеров:

  • Фиксация тултипа. Стандартный тултип Highcharts исчезает при уходе мыши. Нативной поддержки «прилипающего» тултипа в 8-м мажоре не было — она появилась только в 12-й версии. Мы реализовали это через переопределение внутренних методов: перехватывали события мыши, подменяли логику скрытия. Код работал, но был хрупким.

  • Кастомный рендер тултипа. Formatter возвращает HTML‑строку, что означ��ет ручную сборку разметки конкатенацией. Никакого React, никаких компонентов. В итоге рендер тултипа превратился в отдельный слой с собственной логикой шаблонизации.

  • Клик по легенде. Стандартное поведение переключает видимость серии. Переопределить его полностью, не сломав остальное, нетривиально: обработчик приходилось вешать через внутренние события и аккуратно отменять дефолтное поведение.

  • Темизация. Цвета, шрифты и отступы в Highcharts задаются через JavaScript‑конфигурацию и применяются как inline‑стили. Поверх этого мы держали отдельный SCSS‑файл для переопределения базовых стилей, который нужно было синхронизировать вручную при каждом обновлении палитры в Gravity UI.

Каждый такой обходной путь — это не изолированная проблема, а вклад в общий техдолг. Один хак живёт сам по себе, два можно удержать в голове, но к десятому они начинают взаимодействовать: правишь одно — ломается другое. Сложность поддержки растёт нелинейно, а каждое обновление Highcharts превращается в аудит: что из наших обходных путей на этот раз тихо сломалось.

Оценка альтернатив

Прежде чем писать своё, мы честно прошлись по существующим решениям. Основными критериями были:

  • Открытая лицензия — MIT или совместимая, никаких платных тиров и OEM‑соглашений.

  • Полная кастомизация рендера и стилей — возможность контролировать внешний вид любого элемента без обходных путей.

  • Встраивание HTML — тултип, подписи на графике, подписи осей должны рендериться как полноценная HTML‑разметка, а не как SVG‑текст или строки‑шаблоны.

Смотрели на ECharts, Recharts, Plotly, Chart.js и D3.

Chart.js и Plotly отпали быстро: Canvas‑рендер по умолчанию закрывает возможности кастомизации стилей через CSS, а встраивание произвольного HTML в элементы графика там не предусмотрено.

Recharts — React‑first, SVG, MIT. Хорошо подходит для простых дашбордов, но глубокая кастомизация форм и поведения быстро упирается в ограничения внутреннего API.

ECharts был наиболее близким вариантом: богатый набор типов, гибкая конфигурация, поддержка кастомных рендереров. Но при ближайшем рассмотрении он не закрывал один из ключевых критериев: полноценное встраивание HTML в произвольные части графика. Для наших кейсов это было принципиально. Плюс к этому — vendor lock никуда не исчезал: MIT‑лицензия снимала юридический вопрос, но зависимость от чужого roadmap и цикла обновлений оставалась.

D3 — не библиотека графиков, а набор примитивов: шкалы, генераторы форм, трансформации данных. Сам по себе не решает задачу, но даёт идеальный фундамент для построения своего решения поверх него.

Вывод оказался логичным: взять D3 как основу и построить поверх него собственную библиотеку — это давало и свободу, и контроль, и снимало зависимость от любого вендора целиком.

Почему D3, а не что‑то другое

D3 не рисует графики. Это набор инструментов для работы с данными и DOM: шкалы, генераторы геометрических форм, трансформации. Именно это нам и нужно — примитивы, из которых можно собрать любую визуализацию, не ограничиваясь тем, что предусмотрел автор библиотеки.

D3 даёт scaleLinear, scaleBand, scaleUtc для осей, d3.line(), d3.arc(), d3.area() для фигур, d3.extent() и d3.group() для трансформаций данных. Всё остальное — наше.

Архитектура @gravity‑ui/charts

Входная точка — объект‑конфиг

Пользователь передаёт в компонент один объект с данными и настройками: серии, оси, заголовок, легенду, тултип. Это осознанный выбор в пользу декларативного подхода — большая часть конфига легко сериализуется, логируется и передаётся между системами.

Там, где декларативного описания недостаточно, конфиг принимает функции: кастомный рендерер тултипа, обработчики кликов, форматтеры меток осей. Это не противоречие, а намеренная граница — структура данных остаётся предсказуемой, а точки расширения явными.

Поток данных

Внутри библиотеки конфиг проходит несколько стадий обработки:

  • Нормализация. Входные данные приводятся к единому внутреннему формату. На этом этапе проставляются дефолты, разрешаются неоднозначности, данные каждой серии типизируются.

  • Подготовка серий. Каждый тип графика (линия, bar, pie и так далее) обрабатывается своим модулем. Назначаются цвета, формируются метаданные для легенды.

Пример для radar chart
Пример для radar chart
  • Построение шкал и осей. На основе данных D3 строит шкалы: линейные, логарифмические, временны́е, категорийные. Шкалы определяют, как значения из данных переводятся в пиксельные координаты.

Пример для heatmap chart
Пример для heatmap chart
  • Рендер форм. Каждый тип серии рисует свои SVG‑элементы: линии, прямоугольники, дуги, точки. D3 предоставляет генераторы форм, React управляет жизненным циклом элементов.

SVG HTML: гибридный рендер

Основной рендер — SVG. Это даёт чёткие границы, масштабирование без потери качества и полный контроль над позиционированием.

Но SVG не умеет переносить текст, не поддерживает богатую HTML‑разметку внутри элементов. Для data labels с переносами, кастомных тултипов и интерактивных элементов поверх графика используется отдельный HTML‑слой — абсолютно спозиционированный div, который накладывается на SVG и синхронизируется с его координатами.

Система событий

Взаимодействия пользователя с графиком — ховер, click, движение мыши — обрабатываются через централизованную шину событий на основе d3.dispatch. Это позволяет разным частям интерфейса (тултип, crosshair, легенда) реагировать на одно событие независимо и согласованно, без прямых зависимостей между компонентами.

Но у этого подхода есть ещё одно важное следствие — производительность. Движение мыши по графику генерирует события с высокой частотой. Если на каждое такое событие запускать React‑рендер, это приведёт к дорогим пересчётам всего дерева компонентов. Вместо этого часть обновлений — например, ховер форм, поиск ближайшей точки к поинтеру — применяется напрямую через D3, минуя React. Компонент не перерисовывается: D3 просто обновляет нужные DOM‑атрибуты. React включается только там, где это действительно необходимо — при изменении структуры или состояния, которое влияет на весь граф.

Миграция без остановки продукта

Путь данных от источника до графика

Прежде чем говорить о смене библиотеки, важно понять, как вообще устроен рендер графиков в DataLens — потому что именно эта архитектура определила, как мы могли мигрировать.

Когда пользователь открывает чарт (или этот чарт рендерится на дашборде), на наш Node.js‑бэкенд уходит запрос за данными. Node.js‑бэкенд, в свою очередь, ходит в наш Python‑сервис за сырыми данными. Но данные сами по себе — это ещё не график. На Node происходит подготовка: определяется тип визуализации, проверяется, не превышены ли лимиты данных для этого типа, и если всё в порядке — выбирается функция подготовки данных, специфичная для конкретного типа графика.

Ключевой момент: у каждого типа визуализации — своя функция подготовки данных. Для линейного графика — одна, для столбчатого — другая, для area — третья. Результат этой функции и есть конфиг, который летит на клиент. Там он попадает не напрямую в @gravity‑ui/charts, а в @gravity‑ui/chartkit — отдельный пакет, который мы используем для работы с несколькими библиотеками визуализации одновременно. Он динамически подгружает только ту библиотеку, которая нужна для конкретного типа чарта, и предоставляет для всех них единый интерфейс — клиентский код не знает и не думает о том, какая именно библиотека рендерит конкретный тип. 

Помимо маршрутизации, chartkit содержит обвязки поверх самих библиотек: логику, которая не укладывается в рамки конкретной библиотеки, но нужна в DataLens. Например, показ тултипа по тапу на мобильном устройстве — это поведение одинаково нужно и для Highcharts, и для @gravity‑ui/charts, поэтому оно реализовано на уровне chartkit. 

Такие вещи потенциально полезны не только в DataLens. Так что подробный рассказ про @gravity‑ui/chartkit заслуживает отдельной статьи.

Стратегия постепенного перехода

Эта архитектура дала нам возможность мигрировать поэтапно, не переписывая всё сразу.

Мы ввели фича‑флаги на уровне типа визуализации. Логика простая: если флаг для конкретной визуализации установлен в true — Node готовит данные в формате @gravity‑ui/charts и клиент использует новую библиотеку. Если флаг не установлен — данные готовятся в формате Highcharts и рендерится старый код.

Это означало, что в какой‑то момент в DataLens одновременно работали обе библиотеки — каждая для своего набора типов графиков. Такой подход дал несколько преимуществ:

  • Изолированный риск. Баг в новой реализации линейчатой диаграммы не затрагивает столбчатые диаграммы, которые ещё на Highcharts

  • Постепенная проверка. Каждый тип после перехода проходит период наблюдения в продакшне, прежде чем мигрирует следующий

  • Возможность отката. Если что‑то пошло не так, достаточно выключить флаг

Миграция шла в три волны, и порядок был выбран осознанно — от простого к сложному.

Волна 1: pie и treemap. Самый логичный старт — визуализации без осей. Нет шкал, нет crosshair, нет сложной системы координат. Это позволило отработать базовую интеграцию, настроить пайплайн подготовки данных и убедиться, что инфраструктура перехода работает корректно, не рискуя самыми нагруженными типами.

Пример для pie chart
Пример для pie chart

Волна 2: bar‑y, bar‑y normalized, scatter. Следующий шаг — графики с осями, но с важным ограничением: эти типы не используются в режиме split (когда несколько графиков выстраиваются один под другим с общей осью X) и не участвуют в комбинированных диаграммах. Это резко сужало количество крайних случаев и делало переход предсказуемым.

Пример для scatter chart
Пример для scatter chart

Волна 3: area, area normalized, bar‑x, bar‑x normalized, line и их комбинации. Самый сложный этап. Именно эти типы — самые популярные в DataLens: их используют на большинстве дашбордов.

Пример для bar-x chart
Пример для bar‑x chart

К тому же здесь появляются комбинированные диаграммы (например, line bar‑x на одном графике), split‑режим и все нетривиальные сценарии взаимодействия. Каждая из этих визуализаций требовала особого внимания при тестировании, а фича‑флаги давали возможность откатиться, если что‑то шло не так в продакшене.

Технические решения

Объект‑конфиг: знакомо, но лучше. Мы сознательно выбрали объект‑конфиг как способ описания графика. DataLens уже работал с таким подходом, и резкая смена парадигмы создала бы лишний барьер. Структура конфига во многом перекликается с тем, к чему привыкли разработчики: серии, оси, заголовок, тултип — всё на своих местах. Но там, где интерфейс Highcharts казался нам неудачным, мы приняли собственные решения. Не ради оригинальности, а потому что видели конкретные проблемы или неудобства в реальном использовании.

Минимальный пример — линейный график с временной осью:

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

<Chart
    data={{
        series: {
            data: [
                {
                    type: 'line',
                    name: 'Выручка',
                    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: 'тыс. руб.'}}],
    }}
/>

Получается так:

Пример для line chart
Пример для line chart

Но можно сделать линейный график и посложнее:

Пример для line chart с двумя осями Y
Пример для line chart с двумя осями Y

Разумные дефолты и кастомизация там, где нужно. За годы разработки BI‑инструмента мы сформировали понимание того, как должна вести себя библиотека графиков в таком продукте. Типичные сценарии должны работать из коробки — без настройки и без сюрпризов. Там, где дефолтов недостаточно, есть явные точки кастомизации: передать свой React‑компонент как рендерер тултипа, переопределить форматтер метки оси, задать ширину линий. Всё это — через тот же объект‑конфиг, без залезания во внутренности библиотеки.

Нативная интеграция с Gravity UI. DataLens построен на Gravity UI — дизайн‑системе с компонентами, иконками и CSS‑темизацией. Темизация работает через CSS‑переменные @gravity‑ui/uikit: подключил тему — и все цвета осей, сеток, меток автоматически подстраиваются под светлый или тёмный режим. Тултип и кнопки в интерфейсе графика — стандартные компонен��ы uikit, которые наследуют доступность, клавиатурную навигацию и обработку событий из дизайн‑системы.

Пример для bar-x chart в темной теме
Пример для bar‑x chart в темной теме

Масштаб без хаоса. Когда типов графиков много, архитектура начинает решать исход. Добавление нового типа не должно требовать правок в ядре или ломать уже работающие. Мы решили это через единую структуру для каждого типа: своя подготовка данных, свой компонент рендера, свои типы — всё в изолированном модуле. Ядро библиотеки знает о существовании типов только через общий контракт, не через конкретные реализации.

Визуальное тестирование. Визуализация — это прежде всего то, что видит пользователь, поэтому unit‑тестов здесь недостаточно. Каждый тип графика покрыт скриншотными тестами на Playwright, которые запускаются в Docker для воспроизводимости. Изменение в одном модуле не может тихо сломать рендер другого: это сразу видно по упавшему снапшоту.

Итоги и выводы

Переход занял время и потребовал серьёзных инвестиций. Но результат — это не просто «заменили одну библиотеку на другую».Вот, что мы получили:

  • Полный контроль над кодом. Когда что‑то работает не так — мы можем зайти в любое место, понять причину и починить. Нет чёрных ящиков, нет workaround поверх чужого кода, нет ожидания фикса от вендора.

  • Опенсорс. Биб��иотека доступна всем — не только команде DataLens. Это значит внешние contributions, публичные issues, прозрачная история изменений. Проблема, о которой сообщит внешний пользователь, помогает улучшить продукт для всех.

  • Единый визуальный язык. Графики стали органичной частью интерфейса DataLens, а не вставным элементом от стороннего вендора. Темизация, типографика, интерактивные компоненты — всё из одной дизайн‑системы.

Писать свою библиотеку графиков — это не то решение, которое стоит принимать легко. Это большой объём работы, необходимость поддерживать, документировать и развивать ещё один продукт.

Но если ваш инструмент строится вокруг визуализации данных, если у вас нестандартные требования к поведению, если вы хотите полного контроля над внешним видом и глубокой интеграции с дизайн‑системой — vendor lock на чужой библиотеке в какой‑то момент станет потолком. Мы упёрлись в него и решили его убрать.

Что дальше

Библиотека активно развивается, и у нас есть несколько направлений, над которыми мы работаем прямо сейчас.

Framework‑agnostic core. Сегодня @gravity‑ui/charts — это React‑библиотека. Мы планируем выделить ядро — логику подготовки данных, построения шкал, расчёта координат — в отдельный пакет без зависимости от какого‑либо фреймворка. Это откроет два пути: использование на чистом JavaScript без React, и возможность написать тонкие обвязки для Vue, Angular или других фреймворков, не дублируя при этом основную логику.

Range slider для категорийных осей. Компонент range slider сейчас работает с числовыми и временны́ми осями — он позволяет выбрать диапазон и «приблизить» нужный участок данных. Но категорийные оси (например, список стран или имён) не менее важны для BI‑аналитики. Мы дорабатываем слайдер так, чтобы он умел работать и с категориями: выбирать подмножество значений и передавать его в фильтрацию данных.

Документация для контрибьюторов. Сейчас добавить новый тип графики — понятный процесс для тех, кто знает архитектуру библиотеки изнутри. Но для внешнего контрибьютора это не так очевидно. Мы хотим создать подробные гайды: что такое модуль типа, какой контракт он должен выполнять, как написать тесты, как подключить новую визуализацию к общей системе. Цель — чтобы человек, никогда не работавший с кодовой базой, мог самостоятельно добавить новый тип графика по документации.


Библиотека @gravity‑ui/charts открыта под MIT‑лицензией — можете попробовать поработать с ней в вашем проекте.

Будем рады звёздочке на GitHub ⭐