Рисуем графики от руки: кастомный рендерер для Apache ECharts

Небольшой эксперимент: что если графики Apache ECharts рисовать в стиле hand-drawn — как будто наброшены от руки?

Пять типов графиков в «рукописном» стиле

Идея

Мне нравится эстетика «нарисованных от руки» графиков — шероховатые контуры, штриховая заливка, кривоватые линии. Библиотека Rough.js умеет именно это: принимает SVG-путь или канвас-команды и рисует их с лёгкой «небрежностью».

Apache ECharts строит графики через zrender — собственный движок сцены. У zrender есть механизм подключаемых painter-ов: можно зарегистрировать свой класс рендеринга и передать его при инициализации через { renderer: 'rough' }. Это идеальная точка входа.

Как это устроено

1
2
3
4
ECharts (логика графиков)
└─ zrender (сцена, лейауты, анимация)
└─ RPainter ← кастомный painter
└─ Rough.js ← sketchy-рендеринг

RPainter — реализация интерфейса PainterBase. По структуре близко к стандартному CanvasPainter из zrender: управляет canvas-слоями, вызывает цикл перерисовки.

brush() — диспетчер для каждого элемента из отсортированного display list. Для Path-элементов вызывает brushPath, для текста — стандартный canvas, для изображений — drawImage.

brushPath() — ключевая функция. Она не рисует напрямую, а:

  1. Записывает команды zrender-пути в SVGPathRecorder (duck-typed canvas context)
  2. Получает строку SVG-пути (M, L, C, Q, A, Z)
  3. Передаёт её в rough.canvas(canvas).path(svgPath, opts)

SVGPathRecorder — псевдо-canvas-контекст, который перехватывает вызовы PathProxy.rebuildPath() и транслирует их в SVG path data. Главная тонкость — дуга: SVG A не умеет рисовать полную окружность (360°), поэтому она разбивается на две полудуги.

Эвристика roughness

Не все элементы графика стоит делать одинаково «кривыми». Оси, сетка и тики при сильном roughness становятся нечитаемыми.

1
2
roughness: hasFill ? 1.5 : 0.4,
bowing: hasFill ? 1 : 0.3,

Заполненные фигуры (столбцы, области) получают полный sketchy-эффект; stroke-only пути (оси, риски) — минимальный.

Что работает

На скриншоте — пять типов графиков, все через один рендерер:

График Описание
Столбчатый Продажи по дням недели
Группированный Выручка по регионам, три серии
Линейный + area Температура по месяцам, сглаженная кривая
Горизонтальный bar Штат отделов
Gauge Спидометр оборотов двигателя

Шрифт Caveat из Google Fonts подключён через CSS, но ECharts рисует текст через canvas — там CSS не работает. Шрифт нужно указывать явно в опциях через textStyle: { fontFamily: 'Caveat, cursive' }.

Интерактивность (hover, тултипы) работает из коробки — zrender вешает свой HandlerProxy поверх canvas, рендерер тут не причём.

Ограничения и нерешённые проблемы

Градиенты и паттерны не поддерживаются — Rough.js принимает только plain-строки цвета. Такие заливки откатываются к 'none'.

Clip-path анимации не работают. ECharts активно использует clip paths для анимации появления: линия «рисуется» через расширяющийся clip rect, gauge-дуга «заполняется» через clip-сектор. Наш painter — статический снапшот-рендерер без анимационного цикла, поэтому clip paths мы намеренно игнорируем. Из-за этого анимации появления просто не проигрываются — элементы рисуются сразу в финальном состоянии.

Gauge-зоны (цветные сегменты шкалы) не отображаются при первой отрисовке и появляются только после hover. Это оказался нетривиальный баг на пересечении нескольких механизмов zrender: lazy path building, viewport culling в shouldBePainted, и порядка вызовов. Несколько подходов к исправлению ничего не дали — эксперимент есть эксперимент.

Итого

Подключить кастомный painter к ECharts на удивление несложно — интерфейс PainterBase небольшой, а zrender хорошо изолирует renderer от логики графиков. Основная сложность — не в самом рендеринге, а в деталях: как zrender управляет состоянием элементов, dirty flags, clip paths и анимационным циклом.

Результат выглядит мило, хотя и работает не идеально. Код открыт — смотреть на GitHub.