Пользовательские функции в Vega

Функции преобразования данных и пользовательская логика в Vega и Vega-Lite строятся вокруг расширяемой системы выражений, трансформаций и сигналов. Архитектура ориентирована на декларативное описание визуализаций, однако допускает внедрение императивных вычислений в строго определённых точках расширения, что позволяет адаптировать систему под нестандартные сценарии анализа и рендеринга данных.

В основе вычислений Vega лежит язык выражений, применяемый в сигналах, шкалах, вычисляемых полях и параметрах визуальных компонентов. Этот язык компилируется и исполняется через модуль выражений, обеспечивающий безопасное выполнение ограниченного набора операций.

Расширение набора функций происходит через регистрацию пользовательских выражений. В рантайме Vega можно добавлять новые функции в глобальное пространство выражений:

import { expressionFunction } from "vega-expression";

expressionFunction("clamp01", (x) => {
  return Math.max(0, Math.min(1, x));
});

После регистрации функция становится доступной в любом выражении спецификации:

{
  "type": "formula",
  "expr": "clamp01(datum.value / 100)"
}

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

Ограничения и модель безопасности выражений

Система выражений Vega работает в изолированной среде. Пользовательские функции не имеют доступа к DOM, глобальному объекту браузера или произвольным модулям JavaScript. Передача данных осуществляется только через параметры выражения.

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

Типичная ошибка при проектировании расширений — попытка реализовать асинхронную логику внутри выражений. Vega не поддерживает Promise внутри expression API, поэтому все вычисления должны быть синхронными.

Пользовательские трансформации данных

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

Базовый интерфейс кастомного трансформа:

import { Transform } from "vega-dataflow";

class MovingAverage extends Transform {
  constructor(params) {
    super(params);
  }

  transform(_, pulse) {
    const window = this.param("window") || 5;
    const values = pulse.source;

    for (let i = 0; i < values.length; i++) {
      let sum = 0;
      let count = 0;

      for (let j = Math.max(0, i - window + 1); j <= i; j++) {
        sum += values[j].value;
        count++;
      }

      values[i].avg = sum / count;
    }

    return pulse.reflow();
  }
}

После реализации трансформация регистрируется в системе Vega:

import { register } from "vega-dataflow";

register("movingAverage", MovingAverage);

Использование в спецификации Vega:

{
  "type": "movingAverage",
  "window": 10
}

Такая модель позволяет внедрять сложные алгоритмы обработки данных: фильтрацию временных рядов, кластеризацию, агрегации, пользовательские статистические методы.

Интеграция пользовательских трансформаций в Vega-Lite

Vega-Lite как декларативный слой не поддерживает прямое описание классов трансформаций, но транслирует высокоуровневые конструкции в Vega-spec. Пользовательские трансформации подключаются на уровне конфигурации компиляции или через расширение рантайма Vega.

Типовой сценарий интеграции:

import vegaEmbed from "vega-embed";
import { register } from "vega-dataflow";

register("movingAverage", MovingAverage);

const spec = {
  data: { values: dataset },
  transform: [
    {
      type: "movingAverage",
      window: 7
    }
  ],
  mark: "line",
  encoding: {
    x: { field: "date", type: "temporal" },
    y: { field: "avg", type: "quantitative" }
  }
};

vegaEmbed("#vis", spec);

Таким образом, Vega-Lite остаётся декларативным интерфейсом, а расширения реализуются на уровне движка исполнения.

Сигналы как точка внедрения пользовательской логики

Сигналы в Vega представляют реактивную систему переменных, которые могут зависеть от данных, взаимодействия пользователя и других сигналов. Через сигналы часто реализуется пользовательская логика, связанная с интерактивностью.

Пользовательские функции могут использоваться внутри выражений сигналов:

{
  "signals": [
    {
      "name": "normalizedValue",
      "update": "clamp01(mouseX / width)"
    }
  ]
}

Сигналы пересчитываются при изменении зависимостей, формируя реактивный граф вычислений. Это позволяет строить интерактивные визуализации без явного управления состоянием.

Расширение языка выражений через конфигурацию runtime

Помимо программной регистрации функций, Vega поддерживает добавление функций через конфигурацию окружения исполнения. В некоторых сборках Vega Expression Engine возможно определить набор пользовательских функций при инициализации:

import { compile } from "vega-expression";

const expr = compile("scaleValue(x)", {
  functions: {
    scaleValue: (x) => x * 10
  }
});

Подобный подход используется при изоляции окружений или внедрении ограниченного набора вычислений в sandbox-среде.

Особенности сериализации и переносимости

Vega и Vega-Lite используют JSON как основной формат описания визуализаций. Это накладывает ограничение: пользовательские функции не сериализуются вместе со спецификацией.

Следствие этого ограничения:

  • функции должны регистрироваться до выполнения spec;
  • spec остаётся переносимым между окружениями;
  • логика отделяется от декларативного описания.

Такое разделение делает невозможным “самодостаточный” JSON с произвольным JavaScript-кодом, что является осознанным архитектурным решением.

Применение пользовательских функций в вычисляемых полях

Одним из наиболее частых сценариев является использование пользовательских функций в вычисляемых полях (formula transform):

{
  "type": "formula",
  "as": "score",
  "expr": "normalize(datum.raw)"
}

Здесь normalize — заранее зарегистрированная функция. Подобный подход используется для:

  • масштабирования данных;
  • преобразования единиц измерения;
  • расчёта производных метрик;
  • подготовки данных к визуализации.

Взаимодействие с агрегатами и группировками

Пользовательские функции часто комбинируются с агрегирующими трансформациями. В этом случае важно учитывать порядок выполнения операций в dataflow-графе Vega.

Например, сначала выполняется агрегация:

{
  "type": "aggregate",
  "groupby": ["category"],
  "fields": ["value"],
  "ops": ["sum"],
  "as": ["total"]
}

После этого применяется пользовательская функция:

{
  "type": "formula",
  "expr": "scaleTotal(datum.total)"
}

Такая последовательность обеспечивает корректную работу функций, зависящих от агрегированных значений.

Производительность пользовательских расширений

Так как Vega выполняет вычисления в режиме dataflow-графа, пользовательские функции становятся частью критического пути исполнения. Их производительность напрямую влияет на:

  • время пересчёта визуализации;
  • отзывчивость интерактивных графиков;
  • стоимость обновления сигналов.

Поэтому вычисления должны быть минимальными по сложности, избегать вложенных циклов при больших объёмах данных и по возможности переносить тяжёлую обработку в этап подготовки данных вне Vega.

Типичные архитектурные паттерны расширения

Расширения в Vega обычно группируются в несколько устойчивых паттернов:

  • чистые функции преобразования — используются в выражениях и formula;
  • потоковые трансформации — реализуются через Transform API;
  • реактивные вычисления — через signals;
  • гибридные пайплайны — комбинация Vega-Lite spec и Vega runtime расширений.

Каждый из этих подходов решает разные задачи, но все они объединены единым dataflow-движком, в котором пользовательская логика становится частью графа вычислений, а не внешним управляющим кодом.