Интерактивная карта торгового центра на HTML5 canvas

Введение

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

Раз плюнуть, скажете вы, — берём векторную карту торгового центра в svg и дополняем её данными. Красиво, современно, быстро. Даже есть готовые решения типа jVectorMap.

Только вот векторных карт нужных торговых центров нет, есть только те картинки, что предоставлены владельцами центров. Абсолютно разные по стилистике и наполнению. А большое количество центров (порядка 300) не позволяет перерисовать их в векторы быстро и дёшево. Да и добавление новых торговых центров потребует дополнительной работы.

Поэтому было решено использовать HTML5 canvas и для разметки карты, и для показа данных.

Выбор фреймворка

Работать напрямую с canvas API не очень удобно, но уже понапридумана куча инструментов для облегчения работы. Требования к фреймворку в нашем случае:

  1. Объектная модель поверх canvas API.
  2. Способность отрисовывать и масштабировать картинку.
  3. Интерактивность:
    • возможность манипуляции объектами на этапе разметки карты,
    • возможность масштабирования и перемещения по карте.
  4. Возможность экспорта/импорта размеченных объектов.
  5. Наличие детализированных событий.
  6. Высокая скорость отрисовки.

Под рассмотрение попали fabric.js, EaselJS, Raphaël, Paper.js и Processing.js.

Всем требованиям удовлетворяет fabric.js. Учитывая имеющийся небольшой опыт работы с ним, было решено взять его за основу. Далее в примерах использовалась версия 1.4.4.

Холст и отрисовка карты

Возьмём карту:

Карта торгового центра

В разметке страницы создадим незамысловатый холст:

<canvas id="canvas" width="1000px" height="400px" style="border: 1px solid black">

Сделаем из него fabric.js-холст, установив заодно нужные параметры:

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 <= width) {
		// Карта целиком помещается на холст
		maxTransX = (width - baseWidth * scale) / (2 * scale);
		minTransX = (width - baseWidth * scale) / (2 * scale);
	} else {
		// Не влазит
		maxTransX = 0;
		minTransX = (width - baseWidth * scale) / scale;
	}
	// Ограничим смещение пороговыми значениями
	if (transX > maxTransX) {
		transX = maxTransX;
	} else if (transX < minTransX) {
		transX = minTransX;
	}

	// То же самое для оси y
	if (baseHeight * scale <= height) {
		maxTransY = (height - baseHeight * scale) / (2 * scale);
		minTransY = (height - baseHeight * scale) / (2 * 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 applyTransform = function () {
	var maxTransX,
		maxTransY,
		minTransX,
		minTransY,
		group;

	// Рассчитаем пороговые значения для смещения по оси x
	if (baseWidth * scale <= width) {
		// Карта целиком помещается на холст
		maxTransX = (width - baseWidth * scale) / (2 * scale);
		minTransX = (width - baseWidth * scale) / (2 * scale);
	} else {
		// Не влазит
		maxTransX = 0;
		minTransX = (width - baseWidth * scale) / scale;
	}
	// Ограничим смещение пороговыми значениями
	if (transX > maxTransX) {
		transX = maxTransX;
	} else if (transX < minTransX) {
		transX = minTransX;
	}

	// То же самое для оси y
	if (baseHeight * scale <= height) {
		maxTransY = (height - baseHeight * scale) / (2 * scale);
		minTransY = (height - baseHeight * scale) / (2 * 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();
};

Здесь мы использовали 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({x: 550, y: 390}, '#0:500');
	addMarker({x: 460, y: 120}, '#1:300');
	canvas.renderAll();

Результат будет следующим:

Карта торгового центра с 2 нанесёнными метками

Редактирование

Введём режим редактирования — при клике на карту будем создавать новую метку. Для примера нам хватит и простого чекбокса и флага:

	<div><input type="checkbox" onclick="window.isEditing = this.checked" id="editing"/><label for="editing">Editing</label></div>

Теперь можно написать функцию createMarkers:

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 <= 3;
		points[points.length - 2] = points[points.length - 1];
		points.pop();
		// Отмена зоны вообще
		if (isDeleted) {
			canvas.remove(currentEditingZone);
			currentEditingZone = null;
		}
		canvas.renderAll();
		event.preventDefault();
	}
};

Результат

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

Результат

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

Вам также может понравиться

Блог Разработка системы тестирования SQL запросов. Часть 2
29 сентября, 2021
Продолжение истории о фреймворке, разработанном с целью автоматизации и упрощения процесса тестирования сложных SQL-запросов на крупном проекте.
Блог Техники обработки отказов сервиса в микросервисных архитектурах
07 сентября, 2021
Эта статья может быть полезна для тех, кто пострадал от нестабильной работы внешних API: какие бывают стратегии обработки отказов и какой путь избрали мы.
Блог Cоздаём безопасное веб-приложение
17 августа, 2021
Эта статья — своего рода ‘cheat sheet’ для веб-разработчика. Она даёт представление о «программе-минимум» для создания веб-приложения, защищённого от самых распространённых угроз.