Перейти к содержанию

ResizeObserver: диаграмма по размеру контейнера

В уроке о пересечении условие зависело от положения конца каталога. Теперь рассмотрим другую задачу. Рядом с карточками находится маленькая диаграмма числа уроков. Панель может менять ширину независимо от окна браузера, поэтому рисунок должен получать актуальное пространство для своих столбиков. Сигналом будет изменение размера конкретного элемента.

Снимок advanced/lesson-38/ использует компонент 36 и те же четыре записи: 20, 16, 12 и 12 уроков. Пояснение 37 сюда не переносится; это отдельный опыт на общей основе. В архиве продолжения сохранены целые файлы. При подготовке пример не запускался, поэтому описываем ожидаемый результат и порядок самостоятельного разбора. Диаграмма показывает учебные числа каталога, а не статистику настоящих курсов.

Почему недостаточно resize окна

Представим, что пользователь открыл боковую панель или растянул колонку редактора. Область просмотра осталась прежней, а ширина каталога уменьшилась. Подписка только на window.resize не описывает такое изменение. Наш пример имитирует его обычным ползунком: тот назначает желаемую ширину контейнера, а ограничение max-width оставляет панель внутри страницы.

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

Если задача сводится к переходу карточек из двух колонок в одну, сначала примените CSS. В уроке о container queries есть такой подход. Здесь JavaScript действительно должен выполнить действие: заново вычислить координаты рисунка. CSS продолжает управлять внешней шириной, а код только использует полученное пространство. Так ответственность за макет остаётся понятной.

Панель с независимым выходным сообщением

Полный index.html нового снимка:

<!doctype html>
<html lang="ru"><head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Размер контейнера каталога</title>
  <link rel="stylesheet" href="../style.css">
  <link rel="stylesheet" href="./panel.css">
  <script type="module" src="./app.js"></script>
</head><body><main>
  <h1>Каталог и учебная диаграмма</h1>
  <label>Желаемая ширина панели
    <input id="panel-width" type="range" min="320" max="900" value="700">
  </label>
  <div id="catalog-panel">
    <canvas id="course-chart" role="img"
      aria-label="Число уроков: JavaScript 20, HTML/CSS 16, производительность 12, Markdown 12"></canvas>
  </div>
  <p id="size-status" role="status">Измерение ещё не получено.</p>
  <button type="button" id="stop-observer">Отключить наблюдение</button>
  <noscript><p>Учебная диаграмма использует JavaScript.</p></noscript>
</main></body></html>

Диаграмма относится ко всем четырём записям, а отбор компонента меняет только видимые карточки. Это намеренное ограничение: сейчас исследуем геометрию, а не связывание нескольких представлений с состоянием. Доступное имя canvas содержит те же числа. Читатель может получить их без разбора цветных прямоугольников; настоящая диаграмма с меняющимися данными потребовала бы обновлять и текстовый эквивалент.

Статус расположен вне панели. Его обновление не должно определять ширину наблюдаемой области. Кнопка прекращения наблюдения также находится снаружи. При смене положения рисунка нужно понимать, какая часть дерева отвечает за размеры, иначе легко получить обратную связь: измерение меняет содержимое, содержимое меняет измерение, и цикл продолжается.

Создайте panel.css, подключённый после общего оформления:

#catalog-panel {
  box-sizing: border-box;
  width: 700px;
  max-width: 100%;
  padding: 1rem;
  border: 1px solid #bbb;
  writing-mode: horizontal-tb;
}
#course-chart { display: block; width: 100%; height: 160px; background: #fff; }
#course-chart[hidden] { display: none; }

Физическая ширина выбрана сознательно: этот учебный рисунок работает в горизонтальном направлении письма. Рамка и внутренние отступы входят в назначенную ширину благодаря border-box. Поэтому значение 700 в ползунке не означает 700 пикселей места для рисунка. На узком экране дополнительное ограничение уменьшит панель ещё сильнее. Использовать число ползунка напрямую было бы неверно.

Явная высота canvas разрывает зависимость высоты панели от внутреннего размера графического буфера. Сам рисунок имеет собственную систему координат; его ширина будет назначаться программой. Правило для hidden сохранено явно, поскольку декларация display: block иначе может перебить штатное скрытие элемента.

Рисуем известные данные в полученной ширине

Полный chart.js:

export function drawCourseChart(canvas, courses, width) {
  const context = canvas.getContext('2d');
  if (!context) return false;
  canvas.width = width;
  canvas.height = 160;
  const left = 150;
  const available = Math.max(1, width - left - 20);
  const maximum = Math.max(1, ...courses.map(course => course.lessons));
  context.font = '13px sans-serif';
  courses.forEach((course, index) => {
    const y = 12 + index * 36;
    context.fillStyle = '#222';
    context.fillText(`${course.id}: ${course.lessons}`, 8, y + 17);
    context.fillStyle = '#7650a1';
    context.fillRect(left, y, available * course.lessons / maximum, 24);
  });
  return true;
}

Слева выделено 150 пикселей для короткого идентификатора и числа уроков. Оставшееся пространство становится максимальной длиной полосы. У курса JavaScript 20 уроков, поэтому он занимает эту длину. Для HTML/CSS отношение 16 к 20 даёт четыре пятых, для двух оставшихся записей 12 к 20 — три пятых. Это отношения внутри учебного набора, которые легко вывести по данным до открытия страницы.

Присваивание canvas.width сбрасывает графический контекст, включая оформление. Поэтому шрифт и цвета задаются после назначения размеров, а старые прямоугольники не требуется очищать отдельно. Math.max сохраняет положительный диапазон даже при слишком маленьком контейнере. Он не делает узкую диаграмму удобной: нижняя граница учебной панели выбрана именно для читаемых подписей.

Размер буфера здесь равен числу CSS-пикселей. На экране с высокой плотностью рисунок может выглядеть мягче текста. Масштабирование с учётом плотности можно добавить отдельным шагом, но оно не меняет источник наблюдения. В данном опыте важно сначала получить правильное место, а затем выбирать нужную чёткость графики; размеры буфера и размеры CSS не следует смешивать без явного пересчёта.

Наблюдаем содержимое и освобождаем ресурс

Полный модуль observe-chart.js:

import { drawCourseChart } from './chart.js';
export function observeChart(panel, canvas, courses, output) {
  if (!('ResizeObserver' in window)) {
    output.textContent = 'Диаграмма недоступна; числа указаны в каталоге.';
    canvas.hidden = true;
    return () => {};
  }
  let lastWidth = -1;
  const observer = new ResizeObserver(entries => {
    const entry = entries.find(item => item.target === panel);
    if (!entry) return;
    const width = Math.floor(entry.contentRect.width);
    if (width <= 0 || width === lastWidth) return;
    lastWidth = width;
    const drawn = drawCourseChart(canvas, courses, width);
    output.textContent = drawn
      ? `Ширина содержимого: ${width} CSS-пикселей.`
      : 'Canvas недоступен; числа указаны в каталоге.';
    canvas.hidden = !drawn;
  });
  observer.observe(panel);
  return function dispose() {
    observer.disconnect();
    output.textContent = 'Наблюдение отключено. Диаграмма больше не обновляется.';
  };
}

Из массива уведомлений выбираем запись нашей панели и читаем contentRect.width. Это физическая ширина области содержимого. В API также есть логические размеры боксов, однако смешивать их с физическими координатами нашего canvas не требуется. Округляем вниз до целого числа, подходящего для ширины буфера. Нулевая ширина пропускается: скрытая панель не должна порождать бессмысленный рисунок.

lastWidth предотвращает повторную работу при уведомлении, в котором изменилась только высота или дробная часть размера. Мы не назначаем новую ширину самой панели из callback. Такая операция могла бы снова вызвать наблюдатель. Перенос действия в requestAnimationFrame сам по себе не исправил бы ошибочный договор «измерил — увеличил — снова измерил»: сначала нужно убрать причину бесконечного изменения.

Разные наблюдаемые боксы и обработка циклических уведомлений описаны в спецификации Resize Observer. Для нашего рисунка достаточно одного явно выбранного бокса и независимого ограничения панели.

В отсутствие API скрывается только вспомогательный рисунок; числа остаются в карточках. Метод освобождения отключает наблюдатель и явно сообщает, что дальнейших обновлений нет. Он не убирает каталог и не возвращает ширину панели к исходной: эти действия относятся к отдельным частям интерфейса.

Соединяем управление шириной и наблюдение

Замените app.js полностью:

import './catalog-element.js';
import { courses } from '../shared/courses.js';
import { observeChart } from './observe-chart.js';
const panel = document.querySelector('#catalog-panel');
const catalog = document.createElement('pw-course-catalog');
catalog.courses = courses;
panel.prepend(catalog);
const range = document.querySelector('#panel-width');
range.addEventListener('input', () => {
  panel.style.width = `${range.valueAsNumber}px`;
});
const dispose = observeChart(
  panel, document.querySelector('#course-chart'),
  catalog.courses, document.querySelector('#size-status')
);
document.querySelector('#stop-observer').addEventListener('click', dispose);

Главный модуль вставляет каталог перед диаграммой и передаёт копию его проверенных данных в функцию наблюдения. Ползунок меняет желаемую ширину; callback наблюдателя получает уже фактическую. Таким образом, ограничение страницы учитывается автоматически. Изменение окна тоже может менять доступное место, но оно лишь одна из возможных причин изменения панели.

Для самостоятельного разбора начните с широкого окна и уменьшайте значение ползунка. Полосы должны сокращаться, сохраняя отношения длин. Затем сузьте окно при прежнем положении ползунка: статус должен показать фактическое уменьшение пространства. После отключения наблюдения панель продолжит реагировать на ползунок, а рисунок перестанет пересчитываться. Именно этот последний опыт отделяет работу CSS от действия наблюдателя.

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

Оглавление курса