Синхронизация осей между несколькими графиками

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

Любая ось в Nivo строится вокруг диапазона значений (domain). Если несколько графиков должны визуально совпадать по шкале, ключевым становится жёсткое выравнивание domain по X и Y.

Типичная ошибка — позволить каждому графику вычислять domain автоматически:

  • график A: min = 10, max = 200
  • график B: min = 0, max = 500

В результате оси теряют смысловую сопоставимость.

Правильный подход — вынести вычисление доменов на уровень контейнера:

const sharedXDomain = [0, 100];
const sharedYDomain = [0, 1000];

И применять их ко всем графикам:

<ResponsiveLine
    data={dataA}
    xScale={{ type: 'linear', min: sharedXDomain[0], max: sharedXDomain[1] }}
    yScale={{ type: 'linear', min: sharedYDomain[0], max: sharedYDomain[1] }}
/>

<ResponsiveLine
    data={dataB}
    xScale={{ type: 'linear', min: sharedXDomain[0], max: sharedXDomain[1] }}
    yScale={{ type: 'linear', min: sharedYDomain[0], max: sharedYDomain[1] }}
/>

Ключевой принцип: синхронизация осей начинается с синхронизации масштабов.


Централизованное вычисление масштабов

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

function computeGlobalDomain(datasets) {
    let minX = Infinity;
    let maxX = -Infinity;
    let minY = Infinity;
    let maxY = -Infinity;

    datasets.forEach(data => {
        data.forEach(series => {
            series.data.forEach(point => {
                if (point.x < minX) minX = point.x;
                if (point.x > maxX) maxX = point.x;
                if (point.y < minY) minY = point.y;
                if (point.y > maxY) maxY = point.y;
            });
        });
    });

    return {
        x: [minX, maxX],
        y: [minY, maxY]
    };
}

Затем результат передаётся всем графикам через props или контекст.


Синхронизация временных осей

При работе с временными рядами проблема усложняется из-за формата данных Date.

Для Nivo важно, чтобы:

  • тип оси был time
  • формат входных данных совпадал
  • domain задавался в виде Date-объектов
xScale={{
    type: 'time',
    format: 'native',
    precision: 'day',
    min: new Date('2024-01-01'),
    max: new Date('2024-12-31')
}}

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


Синхронизация осей через shared configuration объект

Практически всегда используется паттерн «общая конфигурация»:

const commonAxis = {
    tickSize: 5,
    tickPadding: 8,
    tickRotation: 0,
    legendOffset: 40
};

const commonXScale = {
    type: 'linear',
    min: 0,
    max: 100
};

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

<ResponsiveLine
    data={dataA}
    axisBottom={{
        ...commonAxis,
        legend: 'Время'
    }}
    xScale={commonXScale}
/>

<ResponsiveLine
    data={dataB}
    axisBottom={{
        ...commonAxis,
        legend: 'Время'
    }}
    xScale={commonXScale}
/>

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


Синхронизация сетки и шагов делений

Даже при одинаковом domain графики могут визуально «разъезжаться», если различаются:

  • tickValues
  • automatic tick generation
  • formatting функций

Для строгой синхронизации ticks задаются явно:

const tickValues = [0, 20, 40, 60, 80, 100];

И применяются ко всем графикам:

axisBottom={{
    tickValues,
    tickSize: 5
}}

В более сложных случаях ticks генерируются централизованно:

function generateTicks(min, max, step) {
    const ticks = [];
    for (let i = min; i <= max; i += step) {
        ticks.push(i);
    }
    return ticks;
}

Синхронизация интерактивности между графиками

Синхронизация осей часто сопровождается синхронизацией hover-состояний.

Nivo поддерживает slices внутри одного графика, но между несколькими графиками это реализуется вручную.

Общий event bus через React state

const [hoverX, setHoverX] = useState(null);

Передача обработчиков:

<ResponsiveLine
    data={dataA}
    onMouseM ove={point => setHoverX(point.x)}
    onMouseLe ave={() => setHoverX(null)}
/>

<ResponsiveLine
    data={dataB}
    onMouseM ove={point => setHoverX(point.x)}
/>

Далее используется кастомный слой (custom layer) для отображения вертикальной линии:

const Crosshair = ({ xScale, innerHeight, hoverX }) => {
    if (hoverX === null) return null;

    const x = xScale(hoverX);

    return (
        <line
            x1={x}
            x2={x}
            y1={0}
            y2={innerHeight}
            stroke="black"
        />
    );
};

И подключение слоя:

layers={[
    'grid',
    'markers',
    'areas',
    'lines',
    'points',
    'slices',
    props => <Crosshair {...props} hoverX={hoverX} />
]}

Синхронизация масштаба при зуме и панорамировании

Если графики поддерживают zoom/pan (через кастомную реализацию или внешние библиотеки), необходимо синхронизировать:

  • текущий domain
  • коэффициент масштабирования
  • смещение

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

const [viewDomain, setViewDomain] = useState({
    x: [0, 100],
    y: [0, 1000]
});

И применение:

xScale={{
    type: 'linear',
    min: viewDomain.x[0],
    max: viewDomain.x[1]
}}

Любое изменение масштаба одного графика должно обновлять состояние, которое переиспользуется всеми остальными.


Синхронизация форматирования осей

Даже при одинаковых значениях оси могут выглядеть по-разному из-за formatter функций.

axisBottom={{
    format: value => `${value}%`
}}

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

Поэтому форматирование также выносится в общий слой:

const formatPercent = v => `${v}%`;

И используется везде одинаково.


Согласование padding и layout

В Nivo графики чувствительны к:

  • margin
  • padding вокруг осей
  • размерам легенд

Разные отступы приводят к тому, что оси визуально «сдвигаются».

const commonMargin = {
    top: 20,
    right: 20,
    bottom: 50,
    left: 60
};
<ResponsiveLine
    margin={commonMargin}
/>

Архитектура синхронизации в приложении

Наиболее устойчивый подход — выделение отдельного слоя синхронизации:

  1. Анализ всех datasets
  2. Вычисление global domain
  3. Генерация tick configuration
  4. Хранение hover state
  5. Передача конфигурации вниз через props или context

Пример структуры:

<ChartsSyncProvider>
    <ChartA />
    <ChartB />
    <ChartC />
</ChartsSyncProvider>

Внутри провайдера:

  • computedDomain
  • sharedTicks
  • hoverX
  • zoomState

Типовые ошибки при синхронизации осей

  • разные типы шкал (linear vs time)
  • частично пересекающиеся domain
  • автоматические ticks без контроля
  • различный margin между графиками
  • несогласованные formatter функции
  • локальное вычисление масштаба внутри каждого компонента

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


Поведение при асинхронных данных

Если графики загружаются по API с разной скоростью, синхронизация должна учитывать состояние загрузки:

  • нельзя вычислять domain до завершения загрузки всех datasets
  • требуется fallback состояние
  • возможна пересборка домена при каждом обновлении данных
if (!isLoadedA || !isLoadedB) return null;

или использование промежуточного состояния «pending domain».


Итоговая модель синхронизации

Согласованная система осей в Nivo строится как комбинация:

  • глобального domain
  • общих scale-конфигураций
  • единых tick-правил
  • синхронизированного hover state
  • идентичного layout (margin/padding)
  • централизованного форматирования

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