flatten: разворачивание массивов

В Vega и Vega-Lite данные нередко содержат вложенные массивы:

{
  "category": "A",
  "values": [10, 20, 30]
}

Подобная структура удобна для хранения, но плохо подходит для визуализации. Большинство графиков ожидают плоский набор записей, где каждая строка представляет отдельное наблюдение.

Трансформация flatten предназначена для разворачивания массивов в отдельные строки данных.

После применения flatten предыдущий объект превращается в три строки:

{ "category": "A", "values": 10 }
{ "category": "A", "values": 20 }
{ "category": "A", "values": 30 }

Это одна из ключевых операций при работе:

  • с вложенными JSON-структурами;
  • с API-ответами;
  • с массивами временных рядов;
  • с многомерными данными;
  • с результатами агрегаций;
  • с GeoJSON и иерархическими структурами.

Общий принцип работы

flatten берет поле-массив и создает новую строку для каждого элемента массива.

Исходная запись:

{
  "name": "Server 1",
  "cpu": [20, 35, 40]
}

Результат:

{ "name": "Server 1", "cpu": 20 }
{ "name": "Server 1", "cpu": 35 }
{ "name": "Server 1", "cpu": 40 }

Все остальные поля копируются в каждую новую строку.


flatten в Vega-Lite

Базовый синтаксис

{
  "transform": [
    {
      "flatten": ["values"]
    }
  ]
}

Простейший пример

Исходные данные

[
  {
    "group": "A",
    "values": [5, 10, 15]
  },
  {
    "group": "B",
    "values": [7, 9]
  }
]

Спецификация

{
  "data": {
    "values": [
      {
        "group": "A",
        "values": [5, 10, 15]
      },
      {
        "group": "B",
        "values": [7, 9]
      }
    ]
  },

  "transform": [
    {
      "flatten": ["values"]
    }
  ],

  "mark": "bar",

  "encoding": {
    "x": {
      "field": "values",
      "type": "quantitative"
    },

    "y": {
      "field": "group",
      "type": "nominal"
    }
  }
}

Что происходит после преобразования

До flatten:

group values
A [5,10,15]
B [7,9]

После flatten:

group values
A 5
A 10
A 15
B 7
B 9

Разворачивание нескольких массивов

flatten умеет одновременно разворачивать несколько полей.

Пример

{
  "flatten": ["x", "y"]
}

Исходные данные

[
  {
    "x": [1, 2, 3],
    "y": [10, 20, 30]
  }
]

Результат:

{ "x": 1, "y": 10 }
{ "x": 2, "y": 20 }
{ "x": 3, "y": 30 }

Важное правило синхронизации

Массивы разворачиваются по индексам.

То есть:

x[0] ↔ y[0]
x[1] ↔ y[1]
x[2] ↔ y[2]

Несовпадающая длина массивов

Исходные данные

{
  "x": [1, 2],
  "y": [10, 20, 30]
}

Результат:

{ "x": 1, "y": 10 }
{ "x": 2, "y": 20 }
{ "x": null, "y": 30 }

Недостающие значения заполняются null.


Использование as

По умолчанию развёрнутые данные записываются в исходное поле.

Иногда это неудобно:

  • теряется оригинальный массив;
  • сложно читать трансформации;
  • появляются конфликты имен.

Для переименования используется as.


Пример

{
  "flatten": ["values"],
  "as": ["value"]
}

Результат

Исходно:

{
  "values": [1, 2, 3]
}

После:

{ "value": 1 }
{ "value": 2 }
{ "value": 3 }

Работа с индексами элементов

Очень частая задача — сохранить индекс элемента массива.

Например:

[10, 20, 30]

Нужно получить:

index: 0 → value: 10
index: 1 → value: 20
index: 2 → value: 30

Для этого применяется свойство index.


Пример

{
  "transform": [
    {
      "flatten": ["values"],
      "as": ["value"],
      "index": "position"
    }
  ]
}

Результат

{ "value": 10, "position": 0 }
{ "value": 20, "position": 1 }
{ "value": 30, "position": 2 }

Визуализация временных рядов

flatten особенно полезен при работе с временными рядами, которые приходят массивами.


Исходная структура

[
  {
    "sensor": "A",
    "temps": [21, 22, 23, 24]
  }
]

Построение линейного графика

{
  "data": {
    "values": [
      {
        "sensor": "A",
        "temps": [21, 22, 23, 24]
      }
    ]
  },

  "transform": [
    {
      "flatten": ["temps"],
      "as": ["temperature"],
      "index": "time"
    }
  ],

  "mark": "line",

  "encoding": {
    "x": {
      "field": "time",
      "type": "quantitative"
    },

    "y": {
      "field": "temperature",
      "type": "quantitative"
    }
  }
}

Обработка API-ответов

Многие REST API возвращают массивы внутри объектов.


Типичный JSON

{
  "department": "Sales",
  "employees": [
    { "name": "Alice", "salary": 3000 },
    { "name": "Bob", "salary": 3500 }
  ]
}

Чтобы строить графики по сотрудникам, массив необходимо развернуть.


Vega-Lite

{
  "transform": [
    {
      "flatten": ["employees"]
    }
  ]
}

После этого каждое значение employees становится отдельной строкой.


Ограничения flatten

flatten работает только с массивами.

Если поле содержит:

  • число;
  • строку;
  • объект;
  • null;

то преобразование не создаст ожидаемый результат.


Пример неправильной структуры

{
  "value": 10
}
{
  "flatten": ["value"]
}

Такой код не имеет смысла, потому что value не является массивом.


Вложенные массивы

Структура

{
  "matrix": [
    [1, 2],
    [3, 4]
  ]
}

Один flatten даст:

{ "matrix": [1,2] }
{ "matrix": [3,4] }

Для полного разворачивания потребуется повторная трансформация.


Двойной flatten

{
  "transform": [
    {
      "flatten": ["matrix"]
    },
    {
      "flatten": ["matrix"]
    }
  ]
}

Результат

{ "matrix": 1 }
{ "matrix": 2 }
{ "matrix": 3 }
{ "matrix": 4 }

flatten и calculate

Очень часто после разворачивания требуется вычисление новых полей.


Пример

{
  "transform": [
    {
      "flatten": ["sales"],
      "as": ["amount"]
    },

    {
      "calculate": "datum.amount * 1.2",
      "as": "amountWithTax"
    }
  ]
}

flatten и агрегации

После преобразования данные становятся обычными строками, поэтому можно использовать:

  • aggregate
  • joinaggregate
  • window
  • stack
  • filter

Пример агрегации

{
  "transform": [
    {
      "flatten": ["values"]
    },

    {
      "aggregate": [
        {
          "op": "mean",
          "field": "values",
          "as": "avg"
        }
      ]
    }
  ]
}

flatten и fold

Эти трансформации часто путают.

flatten

Разворачивает массивы.

Было

{
  "values": [1,2,3]
}

Стало

{ "values": 1 }
{ "values": 2 }
{ "values": 3 }

fold

Преобразует несколько колонок в пары ключ-значение.

Было

{
  "a": 10,
  "b": 20
}

Стало

{ "key": "a", "value": 10 }
{ "key": "b", "value": 20 }

flatten в Vega

В Vega трансформация используется аналогичным образом.


Базовый синтаксис

{
  "type": "flatten",
  "fields": ["values"]
}

Полный пример Vega

{
  "$schema": "https://vega.github.io/schema/vega/v5.json",

  "width": 400,
  "height": 200,

  "data": [
    {
      "name": "table",

      "values": [
        {
          "category": "A",
          "values": [1, 2, 3]
        }
      ],

      "transform": [
        {
          "type": "flatten",
          "fields": ["values"]
        }
      ]
    }
  ],

  "scales": [
    {
      "name": "x",
      "type": "linear",
      "domain": {"data": "table", "field": "values"},
      "range": "width"
    },

    {
      "name": "y",
      "type": "band",
      "domain": {"data": "table", "field": "category"},
      "range": "height"
    }
  ],

  "marks": [
    {
      "type": "rect",

      "from": {"data": "table"},

      "encode": {
        "enter": {
          "x": {"value": 0},
          "x2": {"scale": "x", "field": "values"},

          "y": {"scale": "y", "field": "category"},
          "height": {"scale": "y", "band": 1}
        }
      }
    }
  ]
}

Отличия Vega и Vega-Lite

Vega-Lite

{
  "flatten": ["values"]
}

Vega

{
  "type": "flatten",
  "fields": ["values"]
}

Использование с вложенными объектами

Исходная структура

{
  "user": "Alice",
  "metrics": {
    "scores": [10, 20, 30]
  }
}

Обращение к вложенному пути

{
  "flatten": ["metrics.scores"]
}

Производительность

flatten способен существенно увеличивать объем данных.


Пример

Исходно:

1000 объектов

В каждом:

массив из 500 элементов

После flatten:

500 000 строк

Последствия

Возможны:

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

Практические рекомендации

Выполнять фильтрацию до flatten

Лучше:

{
  "transform": [
    {
      "filter": "datum.active"
    },

    {
      "flatten": ["values"]
    }
  ]
}

Хуже:

{
  "transform": [
    {
      "flatten": ["values"]
    },

    {
      "filter": "datum.active"
    }
  ]
}

Во втором варианте сначала создается огромное количество строк.


Не хранить чрезмерно длинные массивы

Массивы по десяткам тысяч элементов лучше предварительно обрабатывать на сервере.


Использовать as

Это улучшает читаемость:

{
  "flatten": ["temperatures"],
  "as": ["temperature"]
}

вместо:

{
  "flatten": ["temperatures"]
}

Типичные сценарии использования

Телеметрия

{
  "device": "sensor-1",
  "readings": [10,11,13,15]
}

Финансовые данные

{
  "symbol": "AAPL",
  "prices": [170,172,175]
}

Логи

{
  "session": "abc",
  "events": ["click","scroll","submit"]
}

Геоданные

{
  "polygon": [
    [10,20],
    [30,40]
  ]
}

Результаты вычислений

{
  "simulation": [0.1,0.4,0.8]
}

Типичные ошибки

Попытка разворачивать объект

Неверно:

{
  "flatten": ["user"]
}

если:

"user": {
  "name": "Alice"
}

Несогласованные массивы

{
  "x": [1,2,3],
  "y": [10]
}

Результат содержит null.


Потеря исходных данных

Без as исходный массив заменяется скалярным значением.


Чрезмерное развертывание

Многократный flatten может создать миллионы строк.


Комбинирование с другими трансформациями

flatten + filter

{
  "transform": [
    {
      "flatten": ["values"]
    },

    {
      "filter": "datum.values > 10"
    }
  ]
}

flatten + window

{
  "transform": [
    {
      "flatten": ["values"]
    },

    {
      "window": [
        {
          "op": "row_number",
          "as": "rank"
        }
      ]
    }
  ]
}

flatten + stack

{
  "transform": [
    {
      "flatten": ["values"]
    },

    {
      "stack": "values"
    }
  ]
}

Когда flatten особенно полезен

Наиболее важные случаи:

  • массивы из API;
  • вложенный JSON;
  • массивы временных рядов;
  • иерархические структуры;
  • массивы координат;
  • события и логи;
  • агрегированные вычисления;
  • массивы измерений;
  • многомерные данные;
  • подготовка данных к агрегации и window-функциям.