Интерактивная карта торгового центра на HTML5 canvas
Заказчиком была поставлена следующая задача – показывать на картах торговых центров статистику по посещаемости магазинов, использованию эскалаторов, лифтов и коридоров. Карту нужно уметь размечать — указывать точки, где показывать статистику и какую конкретно статистику. И, естественно, показывать эту статистику для выбранного периода времени и фильтров. Откуда берутся и где хранятся данные – отдельная большая тема, за скобками данной статьи.
Раз плюнуть, скажете вы – берём векторную карту торгового центра в svg и дополняем её данными. Красиво, современно, быстро. Даже есть готовые решения типа jVectorMap.
Только вот векторных карт нужных торговых центров нет, есть только те картинки, что предоставлены владельцами центров. Абсолютно разные по стилистике и наполнению. А большое количество центров (порядка 300) не позволяет перерисовать их в вектора быстро и дёшево. Да и добавление новых торговых центров потребует дополнительной работы.
Поэтому было решено использовать HTML5 canvas и для разметки карты, и для показа данных.
Выбор фреймворка
- Объектная модель поверх canvas API.
- Способность отрисовывать и масштабировать картинку.
- Интерактивность:
- возможность манипуляции объектами на этапе разметки карты,
- возможность масштабирования и перемещения по карте.
- Возможность экспорта/импорта размеченных объектов.
- Наличие детализированных событий.
- Высокая скорость отрисовки.
Холст и отрисовка карты
Возьмём карту:
В разметке страницы создадим незамысловатый холст:
var element = $('#canvas'), // ещё пригодится для обработки событий canvas = new fabric.Canvas(element.get(0), < selection: false, // отключим возможность выбора группы scale: 1, // установим масштаб по умолчанию renderOnAddRemove: false, // отключим авто-отрисовку, чтобы увеличить скорость для большого числа меток moveCursor: 'default', // сбросим курсоры, чтобы не отвлекали hoverCursor: 'default' >);
Масштабирование и перемещение по карте
Размер карт может быть какой угодно, поэтому необходимо дать пользователю возможность масштабировать и свободно перемещаться по ней, используя мышь. По сути такие манипуляции это трансформация всех объектов на карте, то есть изменение размера и позиции.
Поэтому нам придётся хранить начальное и текущее состояние:
var baseWidth = 0, // начальная ширина baseHeight= 0, // начальная высота baseScale = 1, // начальный масштаб width = 0, // текущая ширина height = 0, // текущая высота transX = 0, // текущее смещение по оси x transY = 0, // текущее смещение по оси y scale = 1; // текущий масштаб в целом
var applyTransform = function () < var maxTransX, maxTransY, minTransX, minTransY, group; // Рассчитаем пороговые значения для смещения по оси x if (baseWidth * scale else < // Не влазит maxTransX = 0; minTransX = (width - baseWidth * scale) / scale; >// Ограничим смещение пороговыми значениями if (transX > maxTransX) < transX = maxTransX; >else if (transX < minTransX) < transX = minTransX; >// То же самое для оси y if (baseHeight * scale else < maxTransY = 0; minTransY = (height - baseHeight * scale) / scale; >if (transY > maxTransY) < transY = maxTransY; >else if (transY < minTransY) < transY = minTransY; >// Сгруппируем все объекты на холсте и применим трансформацию group = new fabric.Group(canvas.getObjects()); group.scaleX = scale / canvas.scale; group.scaleY = scale / canvas.scale; group.left = group.getWidth() / 2 + transX * scale; group.top = group.getHeight() / 2 + transY * scale; group.destroy(); // Обновим глобальный масштаб на холсте canvas.scale = scale; // Отрисуем холст с изменёнными объектами canvas.renderAll(); >;
var setScale = function (scaleToSet, anchorX, anchorY) < var zoomMax = 5, // максимально 5-ти кратное увеличение zoomMin = 1, // минимальное увеличение - реальный размер картинки zoomStep; // необходимое изменение масштаба // Ограничим масштаб, если нужно if (scaleToSet >zoomMax * baseScale) < scaleToSet = zoomMax * baseScale; >else if (scaleToSet < zoomMin * baseScale) < scaleToSet = zoomMin * baseScale; >// Центр масштабирования - точка, которая должна остаться на месте. // Задаётся параметрами anchorX и anchorY. // По сути это позиция курсора в момент масштабирования. if (typeof anchorX != 'undefined' && typeof anchorY != 'undefined') < zoomStep = scaleToSet / scale; // Рассчитаем, на сколько нужно сместить все объекты, // чтобы центр масштабирования остался на месте. transX -= (zoomStep - 1) / scaleToSet * anchorX; transY -= (zoomStep - 1) / scaleToSet * anchorY; >scale = scaleToSet; applyTransform(); >;
var bindContainerEvents= function () < var mouseDown = false, oldPageX, oldPageY, container = $(canvas.wrapperEl); container.mousemove(function (e) < // Непосредственно перемещение if (mouseDown) < // Рассчитываем смещение с учётом масштаба transX -= (oldPageX - e.pageX) / scale; transY -= (oldPageY - e.pageY) / scale; applyTransform(); oldPageX = e.pageX; oldPageY = e.pageY; return false; >>).mousedown(function (e) < // Запомним положение в начале перемещения по карте mouseDown = true; oldPageX = e.pageX; oldPageY = e.pageY; return false; >); $('body').mouseup(function () < mouseDown = false; >); // Масштабирование колесом мыши container.mousewheel(function (event, delta, deltaX, deltaY) < var offset = element.offset(), // положение холста на странице centerX = event.pageX - offset.left, // координата x центра масштабирования centerY = event.pageY - offset.top, // координата y центра масштабирования zoomStep = Math.pow(1.3, deltaY); // шаг масштабирования, удобный для пользователя. setScale(scale * zoomStep, centerX, centerY); // Отключим скроллирование страницы event.preventDefault(); >); >;
Здесь мы использовали jQuery Mousewheel для обработки прокрутки колеса мыши.
Кроме этого, для пользователей touch устройств сделаем отдельную обработку событий. Тогда привычные шаблоны прикосновений «сдвинуть» (однопальцевое касание), «увеличить» и «уменьшить» (двупальцевые касания) будут радовать владельцев таких устройств.
var bindContainerTouchEvents = function () < var touchStartScale, touchStartDistance, container = $(canvas.wrapperEl), touchX, touchY, centerTouchX, centerTouchY, lastTouchesLength, handleTouchEvent = function (e) < var touches = e.originalEvent.touches, offset, currentScale, transXOld, transYOld; if (e.type == 'touchstart') < lastTouchesLength = 0; >if (touches.length == 1) < // Простое перемещение if (lastTouchesLength == 1) < transXOld = transX; transYOld = transY; transX -= (touchX - touches[0].pageX) / scale; transY -= (touchY - touches[0].pageY) / scale; applyTransform(); if (transXOld != transX || transYOld != transY) < e.preventDefault(); >> touchX = touches[0].pageX; touchY = touches[0].pageY; > else if (touches.length == 2) < // Масштабирование if (lastTouchesLength == 2) < currentScale = Math.sqrt( Math.pow(touches[0].pageX - touches[1].pageX, 2) + Math.pow(touches[0].pageY - touches[1].pageY, 2) ) / touchStartDistance; setScale(touchStartScale * currentScale, centerTouchX, centerTouchY); e.preventDefault(); >else < // Момент начала масштабирования, запомним параметры offset = element.offset(); if (touches[0].pageX >touches[1].pageX) < centerTouchX = touches[1].pageX + (touches[0].pageX - touches[1].pageX) / 2; >else < centerTouchX = touches[0].pageX + (touches[1].pageX - touches[0].pageX) / 2; >if (touches[0].pageY > touches[1].pageY) < centerTouchY = touches[1].pageY + (touches[0].pageY - touches[1].pageY) / 2; >else < centerTouchY = touches[0].pageY + (touches[1].pageY - touches[0].pageY) / 2; >centerTouchX -= offset.left; centerTouchY -= offset.top; touchStartScale = scale; touchStartDistance = Math.sqrt( Math.pow(touches[0].pageX - touches[1].pageX, 2) + Math.pow(touches[0].pageY - touches[1].pageY, 2) ); > > lastTouchesLength = touches.length; >; container.bind('touchstart', handleTouchEvent); container.bind('touchmove', handleTouchEvent); >;
Магия трансформаций и обработки событий взята из jVector.
Наконец, загрузим карту и отрисуем её:
fabric.util.loadImage('Map.png', function(img) < var map = new fabric.Image(img), curBaseScale; if (('ontouchstart' in window) || (window.DocumentTouch && document instanceof DocumentTouch)) < bindContainerTouchEvents(); >else < bindContainerEvents(); >// Установим начальные и текущие размеры baseWidth = map.width; baseHeight = map.height; width = element.width(); height = element.height(); // Отключим любую возможность редактирования и выбора карты как объекта на холсте map.set(< hasRotatingPoint: false, hasBorders: false, hasControls: false, lockScalingY: true, lockScalingX: true, selectable: false, left: map.width / 2, top: map.height / 2, originX: 'center', originY: 'center' >); canvas.add(map); // Отмасштабируем, чтобы сразу видеть всё карту curBaseScale = baseScale; if (width / height > baseWidth / baseHeight) < baseScale = height / baseHeight; >else < baseScale = width / baseWidth; >scale *= baseScale / curBaseScale; transX *= baseScale / curBaseScale; transY *= baseScale / curBaseScale; canvas.setWidth(width); canvas.setHeight(height); applyTransform(); // Метки на карте, добавим позднее createMarkers(); >);
Метки на карте
Мы уже получили удобную в использовании карту, осталось научиться наносить на неё метки и затем показывать их с необходимыми данными.
Лучше всего использовать векторные объекты, тогда при любом увеличении карты они будут выглядеть отлично.
Помимо метки добавим ещё и текст, показывающий статистику посещений этой точки карты. Текст будет читаем на любой карте, если его обернуть прямоугольником со сплошной заливкой. Для правильного позиционирования текста и обёртки относительно друг друга установим originX и originY в ‘center’.
var markerColor = '#2567d5'; var addMarker = function(point, text) < // Сама метка var marker = new fabric.Path('m 11,-19.124715 c -8.2234742,0 -14.8981027,-6.676138 -14.8981027,-14.9016 0,-5.633585 3.35732837,-10.582599 6.3104192,-14.933175 C 4.5507896,-52.109948 9.1631953,-59.34619 11,-61.92345 c 1.733396,2.518329 6.760904,9.975806 8.874266,13.22971 3.050966,4.697513 6.023837,8.647788 6.023837,14.667425 0,8.225462 -6.674629,14.9016 -14.898103,14.9016 z m 0,-9.996913 c 2.703016,0 4.903568,-2.201022 4.903568,-4.904687 0,-2.703664 -2.200552,-4.873493 -4.903568,-4.873493 -2.7030165,0 -4.903568,2.169829 -4.903568,4.873493 0,2.703665 2.2005515,4.904687 4.903568,4.904687 z"', < width: 40, height: 80, scaleX: scale, scaleY: scale, left: point.x, top: point.y, originX: 'center', originY: 'center', fill: markerColor, stroke: '#2e69b6', text: text // сохраним текст в объекте для импорта/экспорта >), // Текст textObject = new fabric.Text(text, < fontSize: 30, originX: 'center', fill: markerColor, originY: 'center' >), // Обёртка вокруг текста background = new fabric.Rect(< width: 100, height: 40, originX: 'center', originY: 'center', fill: 'white', stroke: 'black' >), // Сгруппируем их для правильного позиционирования textGroup = new fabric.Group([background, textObject], < scaleX: scale, scaleY: scale, left: point.x + 20 * scale, // необходимо учитывать масштаб top: point.y - 30 * scale // необходимо учитывать масштаб >); canvas.add(marker); canvas.add(textGroup); >;
addMarker(, '#0:500'); addMarker(, '#1:300'); canvas.renderAll();
Результат будет следующим:
Редактирование
Введём режим редактирования — при клике на карту будем создавать новую метку. Для примера нам хватит и простого чекбокса и флага:
var createMarkers = function() < var markersCount = 0; // Флаг режима редактирования window.isEditing = false; // Создание новой метки canvas.on('mouse:down', function (options) < var position; if (!window.isEditing) < return; >// Получим абсолютную координату на холсте position = canvas.getPointer(options.e); // Текст - номер и случайное число addMarker(position, '#' + markersCount++ + ':' + Math.round(Math.random() * 1000)); // Не забываем отрисовку canvas.renderAll(); >); >;
С такой функцией можно превратить карту в месиво из меток или в произведение искусства:
Естественно, можно добавить возможность выбора цвета и вида метки, связанной с ней информации и так далее. Например, меткой может быть значок эскалатора:
var circle = new fabric.Circle(< radius: 22.5 >), path1 = new fabric.Path('M31,31h-2L15,17H9c-1.1027832,0-2,0.8971558-2,2c0,1.1027832,0.8972168,2,2,2h2l14,14h6c1.1027832,0,2-0.8972168,2-2C33,31.8971558,32.1027832,31,31,31z', < originX: 'center', originY: 'center', fill: markerColor >), path2 = new fabric.Path('M22.5,2C11.1782227,2,2,11.1781616,2,22.5S11.1782227,43,22.5,43S43,33.8218384,43,22.5S33.8217773,2,22.5,2z M26.5,7C27.8806152,7,29,8.1192627,29,9.5c0,1.3806763-1.1193848,2.5-2.5,2.5c-1.3807373,0-2.5-1.1193237-2.5-2.5C24,8.1192627,25.1192627,7,26.5,7z M26.5,13.0023804c1.380249-0.0330811,2.5,0.2385864,2.5,3s0,8,0,8l-6-7C23,17.0023804,25.0908203,13.0361938,26.5,13.0023804z M31,38h-7L10,24H9c-2.7614746,0-5-2.2385864-5-5s2.2385254-5,5-5h7l14,14h1c2.7613525,0,5,2.2385864,5,5S33.7613525,38,31,38z', < originX: 'center', originY: 'center', fill: markerColor >), marker = new fabric.Group([circle, path1, path2], < width: 40, height: 80, scaleX: scale, scaleY: scale, left: point.x, top: point.y, originX: 'center', originY: 'center', fill: markerColor, >);
Кроме того, fabric.js позволяет редактировать объекты — перемещать, изменять размер, поворачивать и т.д. Значит пользователь получает широкие возможности по созданию читаемой и удобной для анализа картинки.
Зоны
В нашем случае заказчик хотел ещё и уметь выделять на карте зоны и показывать статистику по этим зонам. Мы решили использовать в качестве зоны стандартный прозрачный многоугольник с произвольным количеством точек, размечаемых пользователем кликами мыши. Такой многоугольник всегда замкнут и легко наносится на карту. Завершать зону будем при двойном клике. А убирать последнюю добавленную точку нажатием кнопок backspace или delete — на случай ошибки.
Тогда разметка зоны может быть выполнена следующим образом
canvas.on('mouse:down', function (options) < addExtendZone(options.e); >).on('mouse:move', function (options) < drawZone(options.e); >); $(document).on('dblclick', finishZone).on('keydown', undoZonePoint); // Вспомогательная функция для получения координат, учитывающих текущий масштаб var convertPointToRelative = function(point, object) < return < x: (point.x - object.left) / scale, y: (point.y - object.top) / scale >; >; var addExtendZone = function(mouseEvent) < var position = canvas.getPointer(mouseEvent); // Новая точка уже существующей зоны if (currentEditingZone) < currentEditingZone.points.push(convertPointToRelative(position, currentEditingZone)); return; >// Новая зона - сделаем сразу 3 точки, тогда визуально зона будет линией currentEditingZone = new fabric.Polygon( [< x: 0, y: 0 >, < x: 1, y: 1 >, < x: -1, y: -1 >], < scaleX: scale, scaleY: scale, left: position.x, top: position.y, fill: new fabric.Color(markerColor).setAlpha(0.3).toRgba(), stroke: '#2e69b6', >); canvas.add(currentEditingZone); canvas.renderAll(); >; var drawZone = function(mouseEvent) < var points; if (currentEditingZone) < // При перемещении мыши меняем только последнюю точку, следуя за курсором points = currentEditingZone.points; points[points.length - 1] = convertPointToRelative(canvas.getPointer(mouseEvent), currentEditingZone); canvas.renderAll(); >>; var finishZone = function () < if (!currentEditingZone) < return; >// Уберём последнюю точку, так как клик двойной currentEditingZone.points.pop(); currentEditingZone = null; >; var undoZonePoint = function(event) < // Только backspace и delete if (currentEditingZone && (event.which == 8 || event.which == 46)) < var points = currentEditingZone.points, isDeleted = points.length canvas.renderAll(); event.preventDefault(); > >;
Результат
Собрав всё воедино, мы получили возможность наносить метки и зоны на произвольную карту, выгружать/загружать их по желанию и отрисовывать с конкретными данными по посещениям этих точек или областей на карте. Примерно так:
Таким образом, разоноообразие и функциональность современных технологий и фреймворков позволяет визуализировать данные в приятном и гибком виде с минимальными затратами времени и усилий. HTML5 canvas вкупе с fabric.js дают разработчику инструментарий для создания быстрых и удобных интерактивных систем.
HTML5 Canvas Map — реализация картографического движка
В рамках большого интерактивного веб-ориентированного проекта (подробнее о котором возможно в другом посте) я занимаюсь разработкой картографического движка, реализованного на HTML5 CANVAS. Его разработка дошла до стадии беты и, с одобрения моего руководства, появилось желание продемонстрировать данные карты широкой публике.
Общие сведения
Движок разрабатывался без использования каких-либо специализированных библиотек или фреймворков. Единственная используемая библиотека – jQuery.
Изображения карт – тайлы – сгенерированы с помощью нашей утилиты. Тут еще есть к чему стремиться, так как их оптимизацией мы еще не занимались.
Все отрисовывается на CANVAS’e, за исключением таких элементов как панель дополнительных инструментов и popup’ов меток (хотя в демо по ссылке ниже их все равно нет).
Реализация
Реализация модульная и состоит из следующих основных частей, назначение которых думаю понятно из их названий: CanvasDragger, CanvasEventer, CanvasImgLoader, CanvasMapper, CanvasMarker, CanvasMiniMapper, CanvasResizer, CanvasTools, CanvasZoomer.
Для того чтобы подключить карты достаточно в нужном месте html’a написать следующую строчку:
$(function() < mWrap = new MapsWrapper(< mapDivId: "map2d" // тут указываем ID canvas’a, в котором будет рисоваться карта >); >); MapsWrapper = function(properties) < this.initialize(properties); >; $.extend(MapsWrapper.prototype, < v2DMapDiv : null, v2DMapComponent : null, initialize: function(prop)< this.v2DMapDiv = prop.mapDivId; this.initMap(); >, initMap: function()< var GlobalParams = < staticMapUrl: ["http://gate.looxity.ru:8088/map.html", "http://zain.looxity.ru:8088/map.html", "http://kaph.looxity.ru:8088/map.html"], initCrd : , initZoom : 0.25, zoomList : [1, 0.5, 0.25, 0.1, 0.05, 0.025], miniMap : true, tools : >; this.v2DMapComponent = new CanvasMapper (this.v2DMapDiv); this.v2DMapComponent.initialize(GlobalParams); > >);
- staticMapUrl – хосты, с которых подгружаются тайлы карты
- initCrd – начальные координаты в проекции Гаусса-Крюгера, в данном случае примерно соответствуют нулевому километру автодорог, что рядом с Манежной площадью.
- miniMap – подключение модуля миникарты
- tools – подключение модуля дополнительных инструментов
Внутренняя механика
Или что скрывается за тем или иным действием пользователя. Пройдемся по основным событиям.
Стартуем
При инициализации карт рассчитывается количество тайлов, которое нужно показать, чтобы полностью покрыть canvas. Зная размеры canvas’a, при заданном размере тайлов в 256х256, проделываем данную операцию.
Двигаемся
Далее когда происходит движение карты – dragg – проверяем ситуацию если мы передвинули карту на такое расстояние, что нужно подгрузить новый тайл. Так же проверяем все ли тайлы находятся в области видимости, если нет, то запускается «сборщик мусора»:
unVisibleTilesCollector: function() < for(var cnt = 0; cnt < this.__TILES__.length; cnt++) < if( (this.__TILES__[cnt].canvX + this.tileSize) < 0 || this.__TILES__[cnt].canvX >this.canvas.width || this.__TILES__[cnt].canvY > this.canvas.height || (this.__TILES__[cnt].canvY + this.tileSize) < 0 ) < this.__TILES__.splice(cnt, 1); cnt--; >> >
Масштабируем (zoomIn, zoomOut)
$.extend(this.__ANIM_TILES__, this.mapper.__TILES__)
- средствами canvas’a и с помощью математики происходит уменьшение или увеличение тайлов (в зависимости от того как мы крутим колесико мыши) из копии, сделанной в пп1
MapsWrapper.v2DMapComponent.update()
Работа в браузерах
Работа проверялась в FireFox, Chrome, Safari, Opera и IE последних версий.
Для тех кто все еще не в курсе лишний раз подчеркну следующее. Так как используется canvas, автоматически отпадают все браузеры, не поддерживающие данную технологию, а это — IE версии 8 и ниже и совсем уж старые версии вышеперечисленных браузеров.
TODO List по картам
1. Уменьшение размеров тайлов карты (должно дать ощутимый прирост скорости работы);
2. Слайдер изменения масштаба;
3. Инструмент получения информации по точке на карте (адрес здания, координаты и тп);
4. .