Кастомный внешний tooltip через external

Стандартный tooltip в Chart.js отрисовывается внутри canvas. Такой подход удобен для быстрых графиков, однако создаёт ограничения:

  • сложно применять полноценные CSS-стили;
  • невозможно использовать HTML-разметку;
  • возникают проблемы с адаптивностью;
  • нельзя вставлять изображения, кнопки, таблицы;
  • ограничена анимация;
  • tooltip оказывается привязан к canvas.

Механизм external позволяет полностью отключить встроенный tooltip и заменить его собственной HTML-разметкой.

Главное преимущество — tooltip становится обычным DOM-элементом.


Принцип работы external tooltip

Chart.js предоставляет callback external, который вызывается при каждом обновлении tooltip.

Схема работы:

  1. Пользователь наводит курсор на график.

  2. Chart.js вычисляет данные tooltip.

  3. Вызывается функция external(context).

  4. Разработчик самостоятельно:

    • создаёт HTML-элемент;
    • позиционирует его;
    • наполняет данными;
    • показывает или скрывает.

Базовое отключение встроенного tooltip

Для начала необходимо отключить стандартный tooltip.

options: {
    plugins: {
        tooltip: {
            enabled: false,
            external: externalTooltipHandler
        }
    }
}

Свойства:

Свойство Назначение
enabled: false отключает встроенный tooltip
external подключает пользовательский обработчик

Структура external callback

Функция получает объект context.

function externalTooltipHandler(context) {

}

Внутри находятся:

const { chart, tooltip } = context;
Объект Назначение
chart экземпляр графика
tooltip текущее состояние tooltip

Полный минимальный пример

<canvas id="myChart"></canvas>

<div id="chartjs-tooltip"></div>
const ctx = document.getElementById('myChart');

new Chart(ctx, {
    type: 'line',

    data: {
        labels: ['Jan', 'Feb', 'Mar', 'Apr'],
        datasets: [{
            label: 'Sales',
            data: [12, 19, 7, 15],
            borderColor: 'blue'
        }]
    },

    options: {
        plugins: {
            tooltip: {
                enabled: false,
                external: externalTooltipHandler
            }
        }
    }
});

function externalTooltipHandler(context) {

    const { chart, tooltip } = context;

    let tooltipEl = document.getElementById('chartjs-tooltip');

    if (!tooltipEl) {
        tooltipEl = document.createElement('div');
        tooltipEl.id = 'chartjs-tooltip';
        document.body.appendChild(tooltipEl);
    }

    if (tooltip.opacity === 0) {
        tooltipEl.style.opacity = 0;
        return;
    }

    if (tooltip.body) {

        const title = tooltip.title[0];
        const value = tooltip.body[0].lines[0];

        tooltipEl.innerHTML = `
            <div class="tooltip-title">${title}</div>
            <div class="tooltip-value">${value}</div>
        `;
    }

    const position = chart.canvas.getBoundingClientRect();

    tooltipEl.style.opacity = 1;
    tooltipEl.style.position = 'absolute';

    tooltipEl.style.left =
        position.left + window.pageXOffset + tooltip.caretX + 'px';

    tooltipEl.style.top =
        position.top + window.pageYOffset + tooltip.caretY + 'px';
}

Создание tooltip через DOM

Создавать элемент необходимо только один раз.

Плохой подход:

const div = document.createElement('div');

при каждом движении мыши.

Правильный подход:

let tooltipEl = document.getElementById('chartjs-tooltip');

if (!tooltipEl) {
    tooltipEl = document.createElement('div');
}

Причины:

  • уменьшается количество DOM-операций;
  • повышается производительность;
  • предотвращаются утечки памяти;
  • tooltip работает плавнее.

Скрытие tooltip

Когда курсор покидает точку графика:

tooltip.opacity === 0

необходимо скрыть HTML-элемент.

if (tooltip.opacity === 0) {
    tooltipEl.style.opacity = 0;
    return;
}

Иногда дополнительно используют:

tooltipEl.style.pointerEvents = 'none';

Это предотвращает перехват мыши самим tooltip.


Получение данных tooltip

Заголовок

tooltip.title

Обычно массив строк.

const title = tooltip.title[0];

Основной текст

tooltip.body

Содержит массив элементов.

const value = tooltip.body[0].lines[0];

Цвет dataset

tooltip.labelColors

Пример:

const colors = tooltip.labelColors[0];

console.log(colors.backgroundColor);
console.log(colors.borderColor);

Работа с несколькими datasets

Если график содержит несколько наборов данных:

datasets: [
    {
        label: '2024',
        data: [10, 20, 30]
    },
    {
        label: '2025',
        data: [15, 25, 35]
    }
]

tooltip может содержать несколько строк.


Перебор body

let innerHtml = '';

tooltip.body.forEach((bodyItem, index) => {

    const colors = tooltip.labelColors[index];

    innerHtml += `
        <div class="tooltip-item">
            <span
                style="
                    background:${colors.backgroundColor};
                    width:10px;
                    height:10px;
                    display:inline-block;
                "
            ></span>

            ${bodyItem.lines[0]}
        </div>
    `;
});

Правильное позиционирование tooltip

Chart.js предоставляет координаты:

Свойство Назначение
tooltip.caretX X внутри canvas
tooltip.caretY Y внутри canvas

Но tooltip — HTML-элемент, поэтому требуется преобразование координат.


Получение позиции canvas

const position = chart.canvas.getBoundingClientRect();

Итоговые координаты

tooltipEl.style.left =
    position.left + window.pageXOffset + tooltip.caretX + 'px';

tooltipEl.style.top =
    position.top + window.pageYOffset + tooltip.caretY + 'px';

Почему используются pageXOffset и pageYOffset

Если страница прокручена:

window.pageYOffset

компенсирует scroll страницы.

Без этого tooltip будет смещаться.


CSS-оформление tooltip

Поскольку tooltip является обычным HTML:

#chartjs-tooltip {
    background: #111;
    color: white;

    padding: 12px 16px;

    border-radius: 8px;

    font-family: Arial;
    font-size: 14px;

    pointer-events: none;

    transition: all .15s ease;

    transform: translate(-50%, -120%);
}

Почему transform особенно важен

Без transform tooltip будет появляться:

  • левым верхним углом в точке курсора;
  • визуально “съезжать”.

Корректировка:

transform: translate(-50%, -120%);

даёт:

Значение Эффект
-50% центрирование по X
-120% смещение вверх

Добавление стрелки tooltip

#chartjs-tooltip::after {
    content: '';

    position: absolute;

    left: 50%;
    bottom: -6px;

    transform: translateX(-50%);

    border-width: 6px;
    border-style: solid;

    border-color: #111 transparent transparent transparent;
}

Использование HTML-разметки

В отличие от стандартного tooltip можно вставлять полноценный HTML.

tooltipEl.innerHTML = `
    <div class="tooltip-card">
        <h4>${title}</h4>

        <div class="tooltip-row">
            <strong>Value:</strong>
            ${value}
        </div>

        <button>Details</button>
    </div>
`;

Добавление изображений

tooltipEl.innerHTML = `
    <div class="user-tooltip">
        <img src="avatar.jpg">

        <div>
            <h3>${title}</h3>
            <p>${value}</p>
        </div>
    </div>
`;

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

Наиболее полезная информация находится в:

tooltip.dataPoints

Пример:

const point = tooltip.dataPoints[0];

Доступные свойства

Свойство Назначение
point.label label оси
point.raw исходное значение
point.dataset dataset
point.dataIndex индекс точки
point.datasetIndex индекс dataset

Пример

const point = tooltip.dataPoints[0];

console.log(point.label);
console.log(point.raw);
console.log(point.dataset.label);

Формирование сложного tooltip

const point = tooltip.dataPoints[0];

tooltipEl.innerHTML = `
    <div class="custom-tooltip">

        <div class="tooltip-header">
            ${point.label}
        </div>

        <div class="tooltip-body">

            <div>
                Dataset:
                ${point.dataset.label}
            </div>

            <div>
                Value:
                ${point.raw}
            </div>

        </div>

    </div>
`;

Tooltip с таблицей

let rows = '';

tooltip.dataPoints.forEach(point => {

    rows += `
        <tr>
            <td>${point.dataset.label}</td>
            <td>${point.raw}</td>
        </tr>
    `;
});

tooltipEl.innerHTML = `
    <table>
        <thead>
            <tr>
                <th>Dataset</th>
                <th>Value</th>
            </tr>
        </thead>

        <tbody>
            ${rows}
        </tbody>
    </table>
`;

Анимация появления

#chartjs-tooltip {
    opacity: 0;

    transition:
        opacity .2s ease,
        transform .2s ease;
}

Эффект масштабирования

#chartjs-tooltip {
    transform:
        translate(-50%, -120%)
        scale(.8);
}

#chartjs-tooltip.active {
    transform:
        translate(-50%, -120%)
        scale(1);
}
tooltipEl.classList.add('active');

Ограничение выхода за экран

Частая проблема — tooltip уходит за границы окна браузера.


Проверка правой границы

const rect = tooltipEl.getBoundingClientRect();

if (rect.right > window.innerWidth) {
    tooltipEl.style.left =
        window.innerWidth - rect.width - 20 + 'px';
}

Проверка верхней границы

if (rect.top < 0) {
    tooltipEl.style.top = '20px';
}

Tooltip внутри контейнера

Иногда график расположен внутри блока:

.chart-wrapper {
    position: relative;
}

Тогда tooltip можно вставлять не в body, а в контейнер.

chart.canvas.parentNode.appendChild(tooltipEl);

Это особенно полезно:

  • в dashboard;
  • в модальных окнах;
  • в flex/grid layout;
  • при overflow hidden.

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

Tooltip можно динамически стилизовать.

tooltipEl.className = 'tooltip tooltip-visible';

или:

tooltipEl.classList.add('dark-theme');

Tooltip для тёмной темы

.tooltip-dark {
    background: #1e1e1e;
    color: white;
}

.tooltip-light {
    background: white;
    color: black;
}

Разделение логики

Хорошая практика — выносить создание tooltip в отдельные функции.


Получение элемента

function getOrCreateTooltip(chart) {

    let tooltipEl =
        chart.canvas.parentNode.querySelector('div');

    if (!tooltipEl) {

        tooltipEl = document.createElement('div');

        chart.canvas.parentNode.appendChild(tooltipEl);
    }

    return tooltipEl;
}

Основной handler

function externalTooltipHandler(context) {

    const { chart, tooltip } = context;

    const tooltipEl = getOrCreateTooltip(chart);

    if (tooltip.opacity === 0) {
        tooltipEl.style.opacity = 0;
        return;
    }

    // update content
    // update position
}

Использование template-функций

function renderTooltip(point) {

    return `
        <div class="tooltip">

            <h4>${point.label}</h4>

            <p>${point.raw}</p>

        </div>
    `;
}

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

external вызывается очень часто — практически при каждом движении мыши.

Поэтому важно:

  • минимизировать DOM-операции;
  • избегать лишнего innerHTML;
  • не создавать элементы повторно;
  • не выполнять тяжёлые вычисления;
  • не делать сетевые запросы.

Проблема мерцания tooltip

Если tooltip начинает мигать:

pointer-events: none;

обычно решает проблему.

Причина — tooltip начинает перехватывать курсор.


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

Для сложных tooltip иногда используют debounce.

let timer;

function updateTooltip() {

    clearTimeout(timer);

    timer = setTimeout(() => {

    }, 16);
}

Tooltip с React/Vue

external хорошо подходит для интеграции с framework.

Вместо innerHTML:

setTooltipState({
    x,
    y,
    value
});

Framework уже самостоятельно обновляет DOM.


Пример для React

tooltip: {
    enabled: false,

    external(context) {

        const { tooltip } = context;

        if (tooltip.opacity === 0) {
            setVisible(false);
            return;
        }

        setVisible(true);

        setData({
            x: tooltip.caretX,
            y: tooltip.caretY,
            points: tooltip.dataPoints
        });
    }
}

Tooltip с Tailwind CSS

tooltipEl.innerHTML = `
    <div
        class="
            bg-black
            text-white
            px-4
            py-2
            rounded-lg
            shadow-lg
        "
    >
        ${value}
    </div>
`;

Кастомизация позиции

Tooltip можно смещать вручную.

tooltipEl.style.left =
    position.left +
    tooltip.caretX +
    20 +
    'px';

Привязка к точке графика

Иногда tooltip должен отображаться строго над точкой.

transform: translate(-50%, -100%);

Использование opacity вместо display

Лучше:

tooltipEl.style.opacity = 0;

чем:

tooltipEl.style.display = 'none';

Причины:

  • плавная анимация;
  • меньше reflow;
  • стабильнее производительность.

Полноценный production-пример

function getOrCreateTooltip(chart) {

    let tooltipEl =
        chart.canvas.parentNode.querySelector('.chart-tooltip');

    if (!tooltipEl) {

        tooltipEl = document.createElement('div');

        tooltipEl.className = 'chart-tooltip';

        chart.canvas.parentNode.appendChild(tooltipEl);
    }

    return tooltipEl;
}

function externalTooltipHandler(context) {

    const { chart, tooltip } = context;

    const tooltipEl = getOrCreateTooltip(chart);

    if (tooltip.opacity === 0) {

        tooltipEl.style.opacity = 0;

        return;
    }

    const point = tooltip.dataPoints[0];

    tooltipEl.innerHTML = `
        <div class="tooltip-content">

            <div class="tooltip-title">
                ${point.label}
            </div>

            <div class="tooltip-value">
                ${point.raw}
            </div>

        </div>
    `;

    const position =
        chart.canvas.getBoundingClientRect();

    tooltipEl.style.opacity = 1;

    tooltipEl.style.position = 'absolute';

    tooltipEl.style.left =
        position.left +
        window.pageXOffset +
        tooltip.caretX +
        'px';

    tooltipEl.style.top =
        position.top +
        window.pageYOffset +
        tooltip.caretY +
        'px';
}
.chart-tooltip {

    background: #111;

    color: white;

    padding: 10px 14px;

    border-radius: 8px;

    font-size: 14px;

    pointer-events: none;

    transform: translate(-50%, -120%);

    transition: opacity .15s ease;

    opacity: 0;
}