Блог API Яндекс.Карт

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

24 декабря 2013, 16:54

Сегодня мы опубликовали пост на Хабре, в котором разработчик интерфейсов API Яндекс.Карт Марина Степанова рассказала, почему мы сломали обратную совместимость в кластеризаторе. Вы можете прочитать его также в этом блоге.

С версии 2.0 наш API умеет кластеризовать метки на клиенте. Вот как выглядят метки до и после кластеризации:

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

В этой статье я хочу не просто перечислить новшества в работе с кластеризатором в версии 2.1.4, но и объяснить, зачем нам понадобилось эти новшества плодить. А то вам придется переписывать код, а переписывать код грустно, если не понимаешь, зачем это приходится делать.

Содержание статьи:

1. Отмена асинхронного добавления меток (этот пункт стоит прочитать тем, кто хоть как-то использовал метод objectsaddtomap).

2. Изменение публичных методов кластеризатора.

3. Изменение способа разбиения карты на тайлы (может никто и не заметит).

4. Переименование сущности Cluster в ClusterPlacemark (скорее всего никто не заметит).

5. Изменения в clusterer.balloon и clusterer.hint (стоит прочесть, если вы использовали балуны кластера или хотели добавить кластерам хинты).

6. Задание произвольных иконок кластеров – что нового (стоит читать, если… а ну тут и так понятно).

7. Небольшая доработка опции preset или как изменить цвет кластера при наведении.

8. Префиксирование опций для кластеров и меток в составе кластера.

9. Сводная таблица различий в коде.

10. Сравнение скорости работы версий (для убедительности статьи).

Отмена асинхронного добавления меток

В версии 2.0 геообъекты создавались, добавлялись на карту и отрисовывались сразу, в одном потоке. Чем слабее браузер, чем больше объектов вы добавляли на карту, тем больше была вероятность увидеть сообщение.

image

Такую ситуацию наш разработчик Антон называет научным термином «залипон».

Кластеризатор начинают использовать, когда количество меток на карте много. Когда объектов много, появляется проблема этих самых залипонов. Нужно было что-то решать, и в кластеризаторе было решено по умолчанию сделать асинхронное добавление объектов на карту. Понять, что объекты добавились на карту, можно было по событию objectsaddtomap. Вот так выглядит один из примеров для версии 2.0.

// Открытие балуна кластера с выбранным объектом.

// Поскольку по умолчанию объекты добавляются асинхронно,
// обработку данных можно делать только после события, сигнализирующего об
// окончании добавления объектов на карту.
cluster.events.add('objectsaddtomap', function () {
   
    // Получим данные о состоянии объекта внутри кластера.
    var geoObjectState = cluster.getObjectState(myGeoObjects[1]);
    // Проверяем, находится ли объект находится в видимой области карты.
    if (geoObjectState.isShown) {
       
        // Если объект попадает в кластер, открываем балун кластера с нужным выбранным объектом.
        if (geoObjectState.isClustered) {
            geoObjectState.cluster.state.set('activeObject', myGeoObjects[1]);
            geoObjectState.cluster.balloon.open();
           
            } else {
            // Если объект не попал в кластер, открываем его собственный балун.
            myGeoObjects[1].balloon.open();
        }
    }
   
});

Не могу сказать, что работать с этим было очень удобно. Но лучше неудобно, чем никак – висящий браузер более веский аргумент, чем красота кода.В версии 2.1 мы шагнули в сторону разделения объектов и их отображения на карте. Кто-то мог заметить, что методы getOverlay стали асинхронными и неудобными – вот это как раз оно. В действительности асинхронная отрисовка макетов – это отличный способ оптимизировать процесс добавления объектов на карту.Чтобы лучше понимать суть вещей, посмотрим на путь метки от ее создания до появления на карте. Процесс можно разбить на три этапа:

    • Создание инстанции класса
    • Добавление объекта на карту
    • Отрисовка макета метки в HTML

По первому пункту, думаю, пояснения не нужны. Не очень понятно, чем отличаются этапы два и три. Добавление объекта на карту – это процесс, при котором метка прикрепляется к родительской коллекции (чаще всего к map.geoObjects). Метка получает от этой коллеции некоторые опции, в том числе проекцию карты. После того, как объект метки узнает, в какой проекции отрисована карта, он может спроецировать свою геометрию на плоскость (что и делает). После проецирования координат метки на плоскость становится понятно, в какой пиксельной точке экрана надо нарисовать значок метки. В этот момент этап два завершается.

Третий этап является как раз процессом рисования метки на карте в определенной пиксельной координате. В версии 2.1 мы выполняем первые два этапа синхронно, в одном потоке. А вот отрисовку объекта делаем по таймауту.

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

Другими словами, для перехода на версию 2.1.4 нужно будет убрать из кода подписку на событие objectsaddtomap. Весь код внутри обработчика этого события теперь можно выполнять сразу синхронно после добавления объектов в кластеризатор. (В конце статьи я приведу таблицу с примерами в формате “стало-было”, кому неинтересно читать дальше – можете сразу переходить к ней.)

Изменение публичных методов кластеризатора

В версии 2.0 кластеризатор являлся наследником объекта ymaps.Collection. Он наследовал от коллекции методы add, remove, setParent и прочие нужные, а также each, getIterator и прочие ненужные (я поясню, почему эти методы были ненужными). Казалось, что кластеризатор вполне себе коллекция – в него можно добавлять объекты и удалять объекты обратно. Но кластеризатор мало того что содержит в себе какие-то элементы, он эти элементы может показывать или не показывать на карте, а также сам генерирует дополнительные объекты-кластеры.

Поэтому какую реализацию метода each ни делай, все равно получится не очень логично. То ли надо перебрать все добавленные объекты. То ли надо пройтись по объектам и кластерам. Но кластеры все время перестраиваются. Пришло осознание, что кластеризатор не совсем коллекция. То есть совсем не коллекция.

Теперь кластеризатор имеет простые и понятные публичные методы для доступа к объектам.

    • Clusterer.getGeoObjects — возвращает массив элементов, добавленных в кластеризатор
    • Clusterer.getClusters — возвращает массив кластеров, в данный момент добавленных на карту. Обратите внимание, что это не все кластера, а только видимые в данный момент времени.

Примеры работы с этими методами ищите в нашей документации.

Изменение способа разбиения карты на тайлы

Как известно, мы используем алгоритм грид-кластеризации (кому интересно подробно – вот ссылка на мой древний спич про кластеризатор). Алгоритм прост и поэтому прекрасен (когда кластеризуешь объекты на клиенте, в первую очередь приходится думать о скорости исполнения кода).

Карта разбивалась на квадратные ячейки. Метки, попадающие в одну ячейку, образовывали кластер. Для того чтобы не вызывать вычисления при каждом маленьком драге карты, ячейки объединялись в более крупные квадраты, которые мы называли «кластерные тайлы». Обрабатывались все кластерные тайлы, в которые полностью или частично попадала видимая область карты.

image

В этой версии мы решили не вводить путаницу в понятие слова «тайл». Теперь кластерные тайлы находятся точно там же, где и обычные карточные тайлы и всегда имеют размер 256х256 пикселей. Из этого следует ограничение – размер ячейки кластеризации должен быть а) не больше 256 и б) в тайл должно умещаться целое количество ячеек кластеризации. То есть допустимые значения для размеров ячейки кластеризации 2, 4, 8, 16, 32, 64, 128 и 256.

Теперь хорошие новости – даже если вы зададите неверное значение, мы все равно приведем его к ближайшему корректному и все будет хорошо. Для чего я все это написала, спросите вы. Да просто так. Может быть кому-то будет интересно, а кому-то будет приятно знать, почему немного поменялась ситуация на карте после кластеризации.

Поскольку тайлы имеют небольшой размер и, вообще говоря, граница карты может совпадать с границей тайла, мы ввели дополнительный отступ для видимой области карты – mapViewport (по умолчанию 128 пикселей). То есть всегда обрабатывается немного больше, чем нужно. Зато пользователь скорее всего не заметит перестроений при перемещении карты.

Переименование Cluster в ClusterPlacemark

Я всегда подозревала, что никто, кроме пары человек (я и наш документатор Олеся), не может слету сказать, чем отличается Cluster от Clusterer (если вы понимаете разницу, вы большой молодец).

    • Clusterer — кластеризатор объектов, что-то вроде коллекции.
    • Cluster – группа объектов, сгенерированная кластеризатором. На карте смотрится как метка.

Мы изучили количество переходов на разные страницы документации – страницы Clusterer и Cluster пользуется большой популярностью. И я подозреваю, что люди, кликающие на Cluster – жертвы жестокости и непонимания. Именно поэтому было решено переименовать сущность Cluster в ClusterPlacemark. Когда смотришь на Clusterer и ClusterPlacemark вроде бы становится понятнее, что к чему.

Поскольку в клубе API Карт не проявлялись люди, пользующиеся кластерами отдельно от кластеризатора (следите за суффиксами), это изменение должно пройти абсолютно незамеченным. А ошибочные клики в документации на Cluster вместо Clusterer должны уйти в далекое прошлое. Тем не менее, если вам нравилось читать документацию к объекту Cluster и ее потерю воспримете, как потерю близкого друга, читайте документацию к ClusterPlacemark.

Изменения в clusterer.balloon и clusterer.hint

Начну с приятного – мы наконец добавили возможность показывать всплывающую подсказу к метке кластера. Делать это можно вот таким кодом:

var clusterer = new ymaps.Clusterer();
clusterer.createCluster = function (center, geoObjects) {
    // Создаем метку-кластер с помощью стандартной реализации метода.
    var clusterPlacemark = ymaps.Clusterer.prototype.createCluster.call(this, center, geoObjects),
    geoObjectsLength = clusterPlacemark.getGeoObjects().length,
    hintContent;
    if (geoObjectsLength < 10) {
        hintContent = 'Мало меток';
        } else if (geoObjectsLength < 100) {
        hintContent = 'Нормально так меток';
        } else {
        hintContent = 'Меток навалом';
    }
    clusterPlacemark.properties.set('hintContent', hintContent);
    return clusterPlacemark;
};

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

Теперь про изменение хинтов и балунов в целом. Для работы с балунами в версии 2.0 у каждого кластера создавалось поле .balloon. Открытие балуна выглядело как cluster.balloon.open();

Недостатком этого решения являлась низкая скорость исполнения кода. Ведь если у вас 100 кластеров, у каждого из них может быть балун, а значит каждому кластеру нужно создать и приписать менеджер балуна в поле cluster.balloon. Поэтому в рамках работ по скорости было решено сделать один менеджер балунов и хинтов на кластеризатор. Теперь балун на кластере можно открыть так:

// Открыть балун на конкретной метке-кластере.
clusterer.balloon.open(clusterPlacemark);

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

Задание произвольных иконок кластеров – что нового

В версии 2.0 все геообъекты были равны. Но некоторые были равнее. Метки, отрисованные на canvas, обретали интерактивность с помощью активных областей. Метки, отрисованные с помощью DOM, были интерактивными сами по себе. Это рождало различия в поведении и массу неудобств.

Поэтому в версии 2.1 было решено все объекты унести под горизонт событий (что бы это ни значило). Теперь все метки становятся интерактивными за счет активных областей, наложенных поверх карты. Из-за этого для меток теперь нужно указывать, какой формы и размера должна быть активная область над меткой (при наведении на какую часть картинки курсор должен меняться, метка должна кликаться и ховериться).

Стандартные метки-кластеры имеют 3 вида картинок – большие, средние и маленькие. Эти картинки круглые. Если вы попробуете поводить мышкой над меткой кластера, вы увидите, что активная область метки совпадает с картинкой и является тоже кругом.

imageimageimage

Некоторые пользователи уже столкнулись с тем, что в версии 2.1.3 при замене изображений для иконки кластера активная область все равно остается круглой (а метка вообще говоря квадратная или более того, треугольная).
image

— кастомная метка, а кликабельным был все равно круг.

Мы это поправили и теперь вы можете самостоятельно определять форму активной области для метки. Делать это можно так (пример для круглых меток).

clusterer.options.set({
    clusterIcons: [
    {
        href: ‘images/small.png’,
        size: [20, 20],
        offset: [-10, -10],
        shape: new ymaps.shape.Circle(new ymaps.geometry.pixel.Circle([0, 0], 10))
    },
    {
        href: ‘images/medium.png’,
        size: [30, 30],
        offset: [-15, -15],
        shape: new ymaps.shape.Circle(new ymaps.geometry.pixel.Circle([0, 0], 15))
    },
    {
        href: ‘images/big.png’,
        size: [40, 40],
        offset: [-20, -20],
        shape: new ymaps.shape.Circle(new ymaps.geometry.pixel.Circle([0, 0], 20))
    }
    ]
});

Код получился не очень простой, поэтому есть альтернативный подход – попроще, но погрубее. Если вы не задаете параметр shape при описании иконок, активной станет прямоугольная область над иконкой, которая сформируется на основе параметров size и offset. То есть вот такой код тоже будет работать нормально, просто кликабельной будет прямоугольная область вокруг иконки.
image

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

clusterer.options.set({
    clusterIcons: [
    {
        href: ‘images/small.png’,
        size: [20, 20],
        offset: [-10, -10]
    },
    {
        href: ‘images/medium.png’,
        size: [30, 30],
        offset: [-15, -15]
    },
    {
        href: ‘images/big.png’,
        size: [40, 40],
        offset: [-20, -20]
    }
    ]
});

Еще одно небольшое дополнение. Если вы не хотите показывать содержимое внутри метки кластера, можно выставить опцию clusterIconContentLayout в null и метка будет показываться без цифр внутри clusterer.options.set(‘clusterIconContentLayout’, null);

Небольшая доработка preset для кластера

В клубе API Карт попадались вопросы на тему «как задать стиль для конкретного кластера». На этот вопрос не было хорошего ответа, так как указанные в описании option.presetStorage ключи работали только если их задавать кластеризатору целиком. А для одиночных меток эти ключи не подходили. В этой версии ключи стали универсальными – подходят как для кластеризатора целиком, так и для меток-кластеров в частности.

Изменить цвет кластера при наведении можно вот так.

clusterer.events.add(‘mouseenter’, function (e) {
    var target = e.get(‘target’);
    if (typeof target.getGeoObjects == ‘function’) {
        target.options.set(‘preset’, ‘islands#redClusterIcons’);
    }
});
clusterer.events.add(‘mouseleave’, function (e) {
    var target = e.get(‘target’);
    if (typeof target.getGeoObjects == ‘function’) {
        target.options.set(‘preset’, ‘islands#blueClusterIcons’);
    }
});

Префиксирование опций для кластеров и меток в составе кластера

В кластеризаторе можно найти два вида объектов – метки-кластеры и одиночные объекты, которые не попали ни в одну группу объектов. Иногда возникает необходимость задать опции для тех и других. Понятно, что никому не хочется перебирать все метки и кластеры, чтобы выставить каждой одни и те же опции. Чтобы задать опции на все объекты сразу, можно эти самые опции указать один раз через кластеризатор. А кластеризатор эти опции передаст своим дочерним объектам.

В частности, в версии 2.0 можно было сделать так: clusterer.options.set(‘cursor’, ‘help’); И вид курсора менялся как для одиночных объектов, так и для меток-кластеров.

Пытливый читатель спросит с прищуром: «А что если я хочу задать разные типы курсоров для меток-кластеров и одиночных меток?» Этот случай мы предусмотрели и решили, что метки-кластеры также будут понимать опции, которые имеют префикс «cluster».

сlusterer.options.set({
    // сработает для геообъектов
    Cursor: 'pointer',
    // сработает для меток-кластеров
    clusterCursor: 'help'
});

В этой системе все было прекрасно, кроме случая, когда вы хотели кастомизировать только одиночные метки, не затрагивая метки-кластеры. Если вы хотели поменять только опции для одиночных меток, вы писали clusterer.options.set(‘cursor’, ‘help’); И получали слишком массовый эффект – опции распространялись и на одиночные объекты, и на метки-кластеры. То есть если вы хотели повлиять только на одиночные метки, опции приходилось задавать все равно и для одиночных объектов, и для кластеров.

В версии 2.1 стало чуть удобнее. Теперь все опции для дочерних объектов задаются с префиксами. Для меток-кластеров c префиксом ‘cluster’, для одиночных меток – с префиксом ‘geoObject’. Теперь опции дочерних объектов не зависят друг от друга. Задавайте, какие больше нравится.

// Сработает только для одиночных меток и не тронет кластеры.
сlusterer.options.set('geoObjectCursor', 'help');
Сводная таблица изменений
Было
// Пример 1. Открытие балуна кластера c выбранным объектом.

// Поскольку по умолчанию объекты добавляются асинхронно,
// обработку данных можно делать только после события, сигнализирующего об
// окончании добавления объектов на карту.
cluster.events.add('objectsaddtomap', function () {
    // Получим данные о состоянии объекта внутри кластера.
    var geoObjectState = cluster.getObjectState(myGeoObjects[1]);
    // Проверяем, находится ли объект в видимой области карты.
    if (geoObjectState.isShown) {
        // Если объект попадает в кластер, открываем балун кластера с нужным выбранным объектом.
        if (geoObjectState.isClustered) {
            geoObjectState.cluster.state.set('activeObject', myGeoObjects[1]);
            geoObjectState.cluster.balloon.open();
            } else {
            // Если объект не попал в кластер, открываем его собственный балун.
            myGeoObjects[1].balloon.open();
        }
    }
});
//  Пример 2. Изменение цвета иконки-кластера.

var options = ymaps.option.presetStorage.get(‘islands#redClusterIcons’);
cluster.options.set({
    icons: options.clusterIcons,
    iconContentLayout: options.clusterContentLayout
});
// Пример 3. Создание меток-кластеров без содержимого.

clusterer.options.set(‘clusterIconContentLayout’, ymaps.templateLayoutFactory.createClass(‘’));
// Пример 4. Указание опций для объектов в составе кластера.
сlusterer.options.set({
    clusterBalloonLayout: myClusterBalloonLayout,
    balloonLayout: myPlacemarkBalloonLayout
});
Стало
// Пример 1. Открытие балуна кластера c выбранным объектом.

// Получим данные о состоянии объекта внутри кластера.
var geoObjectState = cluster.getObjectState(myGeoObjects[1]);
// Проверяем, находится ли объект в видимой области карты.
if (geoObjectState.isShown) {
    // Если объект попадает в кластер, открываем балун кластера с нужным выбранным объектом.
    if (geoObjectState.isClustered) {
        geoObjectState.cluster.state.set('activeObject', myGeoObjects[1]);
        clusterer.balloon.open(geoObjectState.cluster);
        } else {
        // Если объект не попал в кластер, открываем его собственный балун.
        myGeoObjects[1].balloon.open();
    }
}
//  Пример 2. Изменение цвета иконки-кластера.

cluster.options.set('preset', 'islands#redClusterIcons');
// Пример 3. Создание меток-кластеров без содержимого.

clusterer.options.set(‘clusterIconContentLayout’, ymaps.templateLayoutFactory.createClass(‘’));
// Пример 4. Указание опций для объектов в составе кластера.
сlusterer.options.set({
    clusterBalloonLayout: myClusterBalloonLayout,
    geoObjectBalloonLayout: myPlacemarkBalloonLayout
});

В остальном все осталось без изменений. Все новшества описаны в документации.

Сравнение скорости работы кластеризатора в версиях 2.0.36 и 2.1.4

Лучше один раз увидеть, чем один раз прочитать и не поверить. Как проводились измерения. За образец был взят вот такой кейс:

<!doctype html>
<html>
<head>
<title> Скорость работы кластеризатора</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<script src="http://api-maps.yandex.ru/2.0.36/?load=package.full &lang=ru-RU&ns=ym" type="text/javascript"></script>
<script type="text/javascript">
ym.ready(function() {
    var map = new ym.Map('map', {
        center: [55.755381, 37.619044],
        zoom: 13
    }),
    coords = [],
    center = [55.755381, 37.619044],
    placemarks = [],
    i;
   
    for (i = 0; i < 10000; i++) {
        coords[i] = [
        center[0] + 0.5 * Math.random() * Math.random() * Math.random() * (Math.random() < 0.5 ? -1 : 1),
        center[1] + 0.7 * Math.random() * Math.random() * Math.random() * (Math.random() < 0.5 ? -1 : 1)
        ];
    }
    var startTime = +new Date();
    for (i = 0, l = coords.length; i < l; i++) {
        placemarks[i] = new ym.GeoObject({
            geometry: {
                type: "Point",
                coordinates: coords[i]
            }
        });
    }
   
    var clusterer = new ym.Clusterer();
    clusterer.add(placemarks);
    map.geoObjects.add(clusterer);
    var stopTime = +new Date();
   
    alert(stopTime - startTime);
});

</script>
</head>
<body>
<div id="map" style="height: 400px; width: 800px;"></div>
</body>
</html>

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

image

Хочу сделать ремарку по поводу IE11. Тесты для всех браузеров запускались на моем ноутбуке, а для IE11 на другом ноутбуке с Windows (у меня macbook). Так что сравнивать скорость его работы с остальными браузерами по этому графику не стоит. А вот оценивать, насколько ускорилось апи от версии к версии, стоит.

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

Заключение

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

Сейчас мы получили немного свободы в связи с выходом новой версии 2.1. Конечно, хотелось изменить еще больше, но нас снова сдерживало то, что мы можем менять только по мелочам (это все же 2.1, а не 3.0).

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

Мне сказали, что у статьи должна быть позитивная концовка, поэтому поздравляю всех с наступающим!

3 комментария
Подписаться на комментарии к посту

Спасибо вам за ваш труд! :-)

Спасибо! :)

В вашем "сlusterer" между прочим, первая буква "с" - русская! Это не было бы бедой, если бы это было только в статье, но это есть в ваших работающих скриптах по сей день, оттуда вы видимо и скопипастили в далеком 2013. Пропишите хоть в документации, что слово сlusterer ни в коем случае нельзя набирать вручную, а только копировать из примеров, а еще лучше получить его в кабинете разработчика, lol.