Personal maps: создаём директиву для подключения Google Maps. Часть 7.

Владимир | | AngularJS, HTML, JavaScript, Web разработка.

personal maps 7 directive

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

Директивы в AngularJS предназначены для работы с DOM (Document Object Model – объектная модель документа) и позволяют создавать новые компоненты или добавлять существующим новые свойства.

В Angular есть множество встроенных директив, часть из них мы уже использовали при создании представлений. Например, ngHide позволяет скрыть элемент в зависимости от значения модели, т.е. изменяет CSS стили элемента.

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

Естественно, создание собственных директив сложнее, чем использование стандартных. Нужно понимать, как решить задачу, и учитывать требования AngularJS к директивам. Но при правильном использовании директивы упрощают разработку приложения, особенно если оно сложное и содержит компоненты с похожим поведением.

Возвращаемся к нашей задаче.

Напомню. Исходный код приложения размещён на GitHub, также доступна демоверсия приложения.

Source Demo

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

События, с которыми работает директива

  • places:updated – это событие возникает когда сервис Places (описание в 4-ой части) получает обновлённый список объектов.
  • place:updated – возникает когда изменяются данные (координаты, заголовок или описание) какого-нибудь объекта.
  • place:added – возникает при создании нового объекта.
  • place:show – возникает, когда пользователь выбирает объект в списке.
  • place:deleted – возникает при удалении объекта.
  • map:pointSelected – это событие отправляет директива, когда пользователь кликает по карте.

Обработчикам всех событий кроме places:updated передается информация об объекте.

Создание директивы

Прежде всего, создаём файл с кодом директивы – js/directives/pm-google-map.js

app.directive('pmGoogleMap', function factory($window, $rootScope, Places) {
    return {
    ...
    }
});

В первом параметре метода directive мы указываем название директивы. AngularJS использует специальное соглашение при формировании этих имён. При использовании названия директивы в HTML разметке, нужно преобразовать все заглавные буквы в прописные и поставить перед ними дефис. Т.е. добавить нашу директиву на страницу можно следующим образом:

<div class="span9 map" pm-google-map>

Если вы хотите чтобы HTML валидатор не показывал ошибок, можно использовать один из следующих вариантов:

<div class="span9 map" data-pm-google-map>
<div class="span9 map" x-pm-google-map>

Структура директивы

Во втором параметре метода directive передаётся функция, которая получает список зависимостей (это компоненты, которые будут доступны внутри директивы) и должна вернуть JS объект, создающий саму директиву. Подробное описание этого объекта вы найдёте в официальной документации, а сейчас мы рассмотрим только те параметры, которые нужны в данном случае.

app.directive('pmGoogleMap', function factory($window, $rootScope, Places) {
    return {
        restrict: 'A',
        link: function(scope, element, attrs) {
        ...
        }
    }
});

Параметр restrict указывает где именно можно использовать данную директиву. A означает attribute, т.е. атрибут любого элемента. Также можно использовать E (element), C (class) и M (comment). Например, если разрешить использование директивы в названии элемента

restrict: 'EA',

то можно будет подключить директиву следующим образом

<pm-google-map class="span9 map">

Для нашего приложения принципиальной разницы нет, но для директив вроде ngHide использование в качестве элемента смысла не имеет.

Параметр link получает функцию, которая выполняет работу с DOM и обработку событий. Эта функция автоматически вызывается при создании директивы. И в нашем случае весь код директивы находится именно в этой функции.

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

Инициализация карты

Для данного приложения я выбрал Google Maps, но точно также можно было использовать и Яндекс.Карты. Кстати, если вы захотите подключить Яндекс.Карты, то заменить придётся только данную директиву. Все остальные компоненты приложения останутся без изменений.

Примечание. Существуют готовые директивы для подключения Google maps, например, эта. Но в данном случае, удобнее создать собственную директиву, которая будет реагировать на события данного приложения. Тем более, что API карт удобный.

Прежде всего, функция link должна инициализировать карту.

link: function(scope, element, attrs) {
	function setMapHeight() {
		var mapHeight = $window.innerHeight - $('header').height() - $('footer').height() - 20;
		element.css('height', mapHeight);
	}
	setMapHeight();

	scope.map = null;

	function initialize() {
		var defaults = {
			center: new google.maps.LatLng(-34.397, 150.644),
			zoom: 8,
			panControl: true,
			zoomControl: true,
			scaleControl: true,
			mapTypeId: google.maps.MapTypeId.ROADMAP
		};
		scope.map = new google.maps.Map(element[0], defaults);
	}
	if (scope.map === null) {
		initialize();
	}
	
	...
	
}

С помощью функции setMapHeight мы устанавливаем высоту карты. Здесь используется сервис $window, который является ссылкой на стандартный объект window. Но т.к. использование window вызовет проблемы при тестировании, использовать рекомендуется именно $window.

Затем с помощью initialize мы создаём карту. Код практически совпадает с примерами в документации к Google Maps API. Но обратите внимание на следующие моменты:

  1. Для директивы автоматически создаётся собственный объект scope. Его назначение и использование мы рассматривали в предыдущей части. В данном случае мы используем его для хранения ссылок на карту и маркеры.
  2. Во втором параметре функция link получает ссылку на объект, для которого установлена директива. Нам он нужен для того, чтобы установить высоту карты так, чтобы она занимала большую часть окна приложения.

Обработка событий

Рассмотрим обработку события places:updated

function getDescription(place) {
	return '<h4>' + place.p_title + '</h4>' + markdown.toHTML(place.p_description);
}

function setMarkerOnClickEvent(marker) {
	google.maps.event.addListener(marker, 'click', function(event) {
		var place = Places.get(marker.pid);
		var latlng = new google.maps.LatLng(place.p_lat, place.p_lng);
		infoBox.setContent(getDescription(place));
		infoBox.setPosition(latlng);
		infoBox.open(scope.map);
	});
}

scope.markers = [];
var infoBox = new google.maps.InfoWindow();

$rootScope.$on('places:updated', function() {
	scope.markers = [];
	infoBox.close();
	var bounds = new google.maps.LatLngBounds();
	angular.forEach(Places.getAll(), function(place) {
		var latlng = new google.maps.LatLng(place.p_lat, place.p_lng);
		bounds.extend(latlng);
		var marker = new google.maps.Marker({
			position: latlng,
			map: scope.map,
			title: place.p_title,
			pid: place.id
		});
		setMarkerOnClickEvent(marker);
		scope.markers.push(marker);
	});
	scope.map.fitBounds(bounds);
});

Сам обработчик устанавливается в строке 18, но для его работы нам нужна пара дополнительных функций.

getDescription – формирует описание объекта. Наше приложение позволяет пользователям вводить описание объектов с использованием синтаксиса markdown. Для этих целей мы используем библиотеку markdown-js. Преобразование из markdown в HTML выполняется с помощью метода markdown.toHTML (строка 2).

setMarkerOnClickEvent – устанавливает обработчик события click для маркеров объектов. Клик по маркеру центрирует карту и открывает infoBox (google.maps.InfoWindow) в котором отображается название и описание объекта.

Появление события places:updated означает, что сервис Places получил новый список объектов, поэтому нам необходимо сформировать новый массив с маркерами и показать его на карте. Для этого, мы получаем с помощью Places.getAll() новый список объектов (строка 22), для каждого из них создаём маркер, устанавливаем для него обработчики событий и показываем их на карте (строки 23-32).

Затем позиционируем карту таким образом, чтобы были видны все маркеры (строка 34).

Принцип работы обработчиков событий place:updated, place:added и place:deleted очень похож. Все их обработчики во втором параметре получают информацию об объекте, для которого нужно выполнить соответствующую операцию (создать, удалить или изменить). Рассмотрим в качестве примера обработчик события place:updated.

$rootScope.$on('place:updated', function(event, place) {
	infoBox.close();
	angular.forEach(scope.markers, function(marker) {
		if (marker.pid == place.id) {
			var latlng = new google.maps.LatLng(place.p_lat, place.p_lng);
			marker.setTitle(place.p_title);
			marker.setPosition(latlng);
			scope.map.setCenter(latlng);
			infoBox.setContent(getDescription(place));
			infoBox.setPosition(latlng);
			infoBox.open(scope.map);
			return false;
		}
	});
});

Т.к. каждому объекту соответствует маркер на карте, мы в цикле перебираем все маркеры и ищем тот, у которого атрибут pid совпадает с id объекта. Если маркер найден, устанавливаем для него новые параметры и открываем infoBox.

Обработчики остальных методов вы найдете на GitHub.

Последний шаг в создании директивы – установка обработчика события click карты.

google.maps.event.addListener(scope.map, 'click', function(event) {
	scope.$apply(function() {
		$rootScope.$broadcast('map:pointSelected', {
			p_lat: Math.round(event.latLng.lat() * 100) / 100,
			p_lng: Math.round(event.latLng.lng() * 100) / 100
		});
	});
});

Этот обработчик выполняет только одну операцию – отправляет событие map:pointSelected с координатами точки, по которой кликнул пользователь. Т.к. вызов обработчика происходит вне контекста AngularJS, необходимо обернуть отправку события в вызов scope.$apply. Если с этим моментом возникают вопросы, советую почитать статью AngularJS and scope.$apply.

Тестирование директивы

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

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

Тесты для нашей директивы не должны зависеть от других компонентов приложения. Поэтому мы должны создать mock объект для сервиса Places.

beforeEach(module('personalmaps', function ($provide) {
	var Places = {
		'get': function() {},
		'getAll': function() {},
		'add': function() {},
		'update': function() {},
		'delete': function() {},
		'save': function() {}
	};
	spyOn(Places, 'getAll').andReturn([
		{
			id: 1,
			p_title: '111',
			p_description: 'test 1',
			p_lat: 0,
			p_lng: 0
		},
		{
			id: 2,
			p_title: '222',
			p_description: 'test 2',
			p_lat: 10,
			p_lng: 10
		}
	]);
	$provide.provider('Places', {
		$get: function() {
			return Places;
		}
	});
	$provide.value('lang', 'en_us');
}));

Этот объект содержит те же самые методы, что и настоящий сервис, но они ничего не выполняют (строки 3-8). Также с помощью функции spyOn мы настраиваем отслеживание вызова метода getAll и формируем массив с данными, которые он должен вернуть (строки 10-25).

Затем мы должны сделать так, чтобы наша директива подключила новый объект через механизм внедрения зависимостей (DI – dependency injection). Для этого используется сервис $provide. Мы вызываем метод provider и передаём ему название сервиса – Places, и объект, содержащий метод $get. Этот метод будет автоматически вызван сервисом $injector, который управляет внедрением зависимостей.

Т.к. в списке зависимостей директивы указан сервис Places, $injector найдёт соответствующий провайдер и попытается получить объект с помощью метода $get. В результате $injector получит наш объект.

У директив есть ещё одна особенность – они «привязаны» к разметке страницы. Во время выполнения теста страницы как таковой у нас нет, поэтому мы должны создать тег вручную и «привязать» нашу директиву к нему. Сделать это можно следующим образом:

beforeEach(inject(function($injector, $rootScope, $compile, Places) {

	spyOn($rootScope, '$broadcast');

	elm = angular.element(
		'<div class="span9 map" pm-google-map></div>'
	);

	scope = $rootScope;
	$compile(elm)(scope);
	scope.$digest();

	spyOn(scope.map, 'setCenter');
}));

Примечание. Функцию beforeEach можно использовать несколько раз.

Обратите внимание на строки 5-7. В них мы создаём точно такой же тег div, как и на странице приложения. Затем компилируем его и передаём в качестве аргумента scope (строка 10). Эти же самые операции выполняет AngularJS при инициализации приложения.

Ещё один момент. Мы отслеживаем вызов метода setCenter (строка 13). Именно по этой причине у нас карта присвоена свойству объекта scope. Если бы в функции link мы записали var map = ..., то отследить центрирование карты было бы невозможно.

Теперь в качестве примера рассмотрим один из тестов

it('should call map.setCenter on place:show', inject(function($compile, $rootScope) {
	$rootScope.$emit('place:show', {
		id: 1,
		p_title: '111',
		p_description: 'test',
		p_lat: 0,
		p_lng: 0
	});
	expect(scope.map.setCenter).toHaveBeenCalled();
}));

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

Ещё один пример.

it('should remove marker from array on place:deleted', inject(function($compile, $rootScope) {
	$rootScope.$emit('places:updated');
	$rootScope.$emit('place:deleted', {id: 1});
	expect(scope.markers.length).toBe(1);
}));

Здесь мы проверяем, что событие place:deleted приводит к уменьшению количества маркеров. Естественно, перед тем как отправлять place:deleted нужно отправить places:updated, чтобы массив маркеров не был пустым.

Остальные тесты работают по такому же принципу, посмотреть вы их можете в файле pm-google-map.spec.js.

В следующей части цикла мы вернёмся к разработке серверной части приложения и рассмотрим создания REST API.

Содержание

  • Sergey Cherdantsev

    Спасибо за статью. С огромным удовольствием читаю.
    Есть вопрос об инициализации некоторых параметров. Например, здесь в коде директивы есть метод initialize(), в коде которого прописан объект defaults с заранее заданными геокоординатами центра карты. Как быть, если код директивы либо контроллера находится в отдельном js-файле, а объект изначально может иметь заведомо разные поля? Например, если требуется показывать карту в центре того города, в котором находится пользователь, а сам город вместе со всеми своими полями выводится в коде html-страницы из php и нет возможности подставить его в код контроллера?

    • Есть несколько вариантов.

      1) Распарсить html-страницу с помощью JS. Т.е. в директиве для получения данных города выполнить что-то вроде.

      var city = $('#city_data').html();

      Этот вариант мне не нравится, т.к. возникает зависимость между директивой и разметкой страницы и изменение разметки «поломает» директиву.

      2) С помощью PHP сформировать JS объект с нужными данными. Пример можно посмотреть в десятой части — переменные lang и translations. Затем эти данные с помощью внедрения зависимостей можно передать директиве.

      3) Получать нужные данные с помощью AJAX запроса. Естественно, нужно будет написать PHP скрипт, который будет этот запрос обрабатывать.

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

  • Sergey Mosolov

    Владимир, здравствуйте!
    Огромное спасибо за труд. Сразу прошу прощения, если вопрос будет очень глупым, но в JS я полный нуб и всех прелестей/сложностей оценить не могу… Скажите, а почему не использовать расширение «egmap» для Yii? (http://www.yiiframework.com/extension/egmap)

    • egmap предназначено для того, чтобы код подключения карт вынести на сторону сервера (Yii), естественно, при этом будет сформирован необходимый JS код, т.к. с картами google нельзя работать без JS, но все настройки будут сделаны на сервере.

      В моем приложении основная клиентская часть (управление картой, список объектов, форма добавления/редактирования объектов) написана на JS (используется AngularJS фреймворк). Между сервером и браузером пересылаются только данные объектов. Поэтому при использовании расширений вроде egmap возникает проблема — как объединить его и остальную клиентскую часть приложения?

      Кроме того, у google maps очень не плохой API, поэтому проще и удобнее создать работать с google maps в рамках AngularJS (создать директиву как описано в этой статье, которая легко работает с остальными модулями приложения), чем заниматься интеграцией egmap с AngularJS.

      В общем, попробуйте добавить карту с помощью egmap и написать код, который будет переставлять маркер при клике по объекту в списке (это решаемая задача). А потом сравните Ваше решение и решение из этой статьи.

  • Anna Amineva

    Добрый день, если вам понравилась эта занимательная статья, вам так же будет интересно почитать: http://makeomatic.ru/blog/2014/06/14/directives_angularjs/