七、基于 Firebase 的 Angular 云映射

我们花了相当多的章节来编写自己的后端系统,以便向客户端返回信息。 在过去几年中,出现了使用第三方云系统的趋势。 云系统可以帮助降低编写应用的成本,因为其他公司提供了我们需要使用的所有基础设施,并负责测试、升级等。 在本章中,我们将使用 Bing 地图团队的云基础设施和 Firebase 来提供数据存储。

本章将涵盖以下主题:

  • 注册 Bing 地图
  • 计费云特性的含义
  • 注册 Firebase
  • 添加映射组件
  • 使用地图搜索功能
  • 使用EventEmitter将子组件事件通知父组件
  • 响应映射事件,添加和删除自己感兴趣的点
  • 在地图上叠加地图搜索结果
  • 整理事件处理程序
  • 保存数据到 Cloud Firestore
  • 配置云 Firestore 鉴权

技术要求

完成的项目可从https://github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter07下载。

下载项目后,您必须使用npm install命令安装软件包要求。

现代应用和向云服务的转移

在本书中,我们一直专注于编写应用,在这些应用中,我们控制应用运行的基础设施,以及数据的物理存储位置。 在过去的几年里,趋势已经从这种类型的应用转向了其他公司通过所谓的云服务提供这种基础设施的模式。 云服务已经成为一个笼统的营销术语,用来描述使用其他公司的按需服务,依靠它们提供应用功能、安全、伸缩、备份等特性的趋势。 这背后的想法是,我们可以通过让其他人为我们处理这些特性来减少资金成本,从而使我们能够以混合和匹配的方式编写应用来使用这些特性。

在本章中,我们将学习使用来自 Microsoft 和谷歌的基于云的服务,因此我们将学习注册这些服务的过程,使用它们的含义,以及如何在最终的 Angular 应用中使用它们。

项目概述

在我们的上一个 Angular 应用中,我们将使用 Bing 地图服务来显示我们每天用来搜索位置的地图类型。 我们将进一步使用微软的 Local Insights 服务,在当前可见的地图区域搜索特定的业务类型。 当我为这本书制定计划时,这是两个最让我兴奋的应用之一,因为我喜欢基于地图的系统。

除了显示地图,我们还可以通过在地图上直接点击来选择感兴趣的点。 这些点将用彩色大头针表示。 我们将从谷歌将这些点的位置及其名称保存在基于云的数据库中。

这个应用应该需要大约一个小时来完成,只要你在 GitHub 上与代码一起工作。

在这一章中,我们将不再详细介绍如何使用npm添加包,或者如何创建 Angular 应用、组件,或者类似的东西,你应该已经熟悉了。

完成后,申请应该是这样的(也许不是放大到纽卡斯尔的泰恩):

开始在 Angular 中学习 Bing 映射

这是我们最后一个 Angular 应用,所以我们将以与前几章中创建应用相同的方式开始创建它。 同样,我们将使用 Bootstrap,而不是 Angular Material。

在本章中我们将集中讨论的包如下:

  • bootstrap
  • bingmaps
  • firebase
  • guid-typescript

因为我们要把代码连接到基于云的服务上,所以我们必须先注册。 在这一节中,我们将看看我们需要做什么来注册。

注册 Bing 地图

如果我们想使用必应地图,我们必须注册必应地图服务。 导航到https://www.bingmapsportal.com,点击登录按钮。 这需要一个 Windows 帐户,所以如果你没有,你需要设置一个。 现在,我们假设你有一个可用的 Windows 帐户:

当我们登录时,我们需要创建一个密钥,我们的应用将使用该密钥向 Bing 地图服务标识自己,以便他们知道我们是谁,并可以跟踪我们的地图使用情况。 从我的帐户选项,选择我的钥匙:

当键屏幕出现时,您将看到一个名为 Click here 的链接,以创建一个新键。 点击链接将显示以下画面:

这个屏幕上的大部分信息都是不言自明的。 如果我们有多个键,并且需要搜索它们,则使用应用名称。 URL 不需要设置,但我喜欢这样做,如果我部署到不同的 web 应用。 这是一种方便的方法来记住哪个键与哪个应用相关联。 因为我们不打算使用付费的企业服务,所以我们唯一可用的键类型是 Basic。

从我们的观点来看,Application 类型可能是这里最重要的字段。 我们可以从许多应用类型中选择,每一种类型都有其可以接受的事务数量的限制。 我们将坚持开发/测试模式,这将我们的计费交易限制在 12 万 5 千笔,在长达一年的时间里。

When we use the Local Insights code in this chapter, this will generate billable transactions. If you don't want to run the risk of incurring any costs, I would recommend that you disable the code that does this searching.

当我们单击 Create 时,映射键就创建了,并且可以通过单击出现的表中的 Show 键或 Copy 键链接来使用。 现在,我们已经设置了 map 键所需的所有内容,接下来让我们注册数据库。

注册 Firebase

Firebase 需要谷歌帐户。 假设我们有一个可用的,我们可以访问 Firebase 的特性在https://console.firebase.google.com/。 当此屏幕出现时,单击 Add project 按钮,开始添加 Firebase 支持的过程:

为项目选择一个有意义的名称。 在创建项目之前,我们应该阅读使用 Firebase 的条款和条件,并勾选复选框,如果我们同意它们。 注意,如果我们选择分享谷歌 Analytics 的使用统计数据,我们应该阅读适当的条款和条件,并勾选控制器-控制器条款复选框:

单击 Create project 后,我们现在可以访问 Firebase 项目。 作为云服务提供商,Firebase 不仅仅是一个提供存储、托管等功能的数据库,我们将使用 database 选项。 当我们点击 Database 链接时,会出现 Cloud Firestore 屏幕,我们需要点击 Create Database 来开始创建数据库的过程:

Whenever I refer to Firebase in this chapter, I am using this as a shorthand way of saying that this is the Firestore feature of the Firebase cloud platform.

在创建数据库时,我们需要选择要应用到数据库的安全级别。 我们有两个选择。 我们可以从锁定数据库开始,以便禁用读写。 然后,必须通过写入规则启用对数据库的访问,数据库将检查这些规则,以确定是否允许写入。

不过,出于我们的目的,我们将以测试模式开始,它允许对数据库进行无限的读写:

Like Bing mapping, Firebase has usage limitations and cost implications. We are creating a Spark plan datastore, which is the free Firebase version. This version comes with hard limits, such as only being able to store 1 GB of data per month, with 50,000 reads a day and 20,000 writes a day. For details around pricing and limitations, please read https://firebase.google.com/pricing/.

一旦我们点击了 Enable 并拥有了可用的数据库,我们需要能够访问 Firebase 为我们创建的密钥和项目细节。 要找到它,请单击菜单上的 Project overview 链接。 >按钮弹出一个屏幕,显示我们需要为我们的项目复制的细节:

现在,我们已经设置好了云基础设施,并获得了所需的密钥和详细信息。 现在我们可以编写应用了。

使用 Angular 和 Firebase 创建必应地图应用

在过去的几年里,增长最快的应用之一是地图应用的爆炸式增长,无论是你的卫星导航系统还是在手机上运行谷歌地图。 在这些应用的下面是由 Microsoft 或谷歌等公司开发的映射服务。 我们将使用必应地图服务为我们的应用添加地图支持。

我们的地图应用有以下要求:

  • 点击一个位置将添加该位置作为一个兴趣点
  • 当添加感兴趣的点时,将显示一个信息框,显示有关该点的详细信息
  • 再次点击一个感兴趣的点将删除它
  • 感兴趣的点将被保存到数据库中
  • 用户将能够移动感兴趣的点,更新数据库中的细节
  • 在可用的地方,业务信息将自动检索和显示

添加映射组件

我们将为这个步骤创建两个 Angular 组件——一个叫MappingcontainerComponent,另一个叫MapViewComponent

我把它们分开是因为我想用MappingcontainerComponent来包含引导基础设施,而MapViewComponent只包含地图本身。 如果你愿意,你可以把它们组合在一起,但为了建立一个清晰的描述,描述每个部分的情况,我在这里创建两个部分比较容易。 这确实意味着我们需要在两个组件之间引入一些协调,这将加强我们在第 5 章和 GraphQL 和 Apollo中提到的EventEmitter行为。

在向这些组件添加任何主体之前,我们需要编写一些模型和服务,以提供地图和数据访问所需的基础设施。

的兴趣点

每个感兴趣的点都用大头针表示,可以用经纬度坐标及其名称表示。

Latitude and longitude are geographic terms that are used to identify exactly where something is on the planet. Latitude tells us how far north or south something is from the equator, with the equator being 0. This means that a positive number indicates we are north of the equator, and a negative number means we are going south from the equator. Longitude tells us how far east or west we are from the vertically centered line of the earth which, by convention, runs through Greenwich in London. Again, if we are moving east, the numbers are positive, while moving west from the line at Greenwich means the numbers will be negative.

表示这个的模型如下所示:

export class PinModel {
  id: string;
  lat: number;
  long: number;
  name: string;
}

We will be referring to both pins and points of interest throughout this section. They both represent the same thing, so we will be using them interchangeably.

当我们创建这个实例时,我们将使用 GUID 来表示它。 由于 GUID 是惟一的,所以在移动或删除它时,我们使用它作为一种方便的方法来查找感兴趣的点。 这不是我们将在数据库中存储模型的确切表示方式,因为这个标识符旨在对地图上的引脚进行跟踪,而不是对数据库中的引脚进行跟踪。 为此,我们将添加一个单独的模型,用于在数据库中存储模型项:

export interface PinModelData extends PinModel {
 storageId: string;
}

我们将其创建为接口,因为 Firebase 希望仅接收数据,而不需要类基础设施。 我们也可以将PinModel创建为一个接口,但是实例化它的语法有点麻烦,这就是为什么我们选择将它创建为一个类。

模型就绪后,现在就可以连接到 Firebase 了。 我们不会直接使用 Firebasenpm,而是使用 Angular 的官方 Firebase 库AngularFirenpm参考文献为@angular/fire

在设置 Firebase 数据存储时,我们获得了创建到它的唯一标识连接所需的设置。 我们将复制这些设置到我们的environment.tsenvironment.prod.ts文件。 当我们将应用发布到生产环境时,Angular 会将environment.prod.ts重新映射到环境文件中,这样我们就可以拥有独立的开发和生产设置:

firebase: {
  apiKey: "AIzaSyC0MzFxTtvt6cCvmTGE94xc5INFRYlXznw",
  authDomain: "advancedtypescript3-mapapp.firebaseapp.com",
  databaseURL: "https://advancedtypescript3-mapapp.firebaseio.com",
  projectId: "advancedtypescript3-mapapp",
  storageBucket: "advancedtypescript3-mapapp.appspot.com",
  messagingSenderId: "6102469443"
}

It's generally bad practice to use the same endpoints for dev and production systems, so you could create a separate Firebase instance to hold the production mapping information and store that in environment.prod.ts.

app.module中,我们将导入AngularFire模块,然后在导入中引用它们。 当我们引用AngularFireModule时,我们调用静态initializeApp方法,该方法将使用environment.firebase设置来建立到 Firebase 的连接。

首先,import陈述如下:

import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';
import { AngularFireStorageModule } from '@angular/fire/storage';

接下来,我们设置 Angular 的imports:

imports: [
  BrowserModule,
  HttpClientModule,
  AngularFireModule.initializeApp(environment.firebase),
  AngularFireStorageModule,
  AngularFirestoreModule
],

对于 Firebase 的功能,将服务作为与数据库本身交互的单点实现是很有帮助的。 这就是为什么我们要创建一个FirebaseMapPinsService:

export class FirebaseMapPinsService {
}

在本课程中,我们将使用AngularFire中的AngularFirestoreCollection特性。 Firebase 公开QueryCollectionReference类型,以便对来自数据库的底层数据执行 CRUD 操作。 AngularFirestoreCollection将此行为打包到方便的流中。 我们将泛型类型设置为PinModelData,表示哪些数据将保存到数据库中:

private pins: AngularFirestoreCollection<PinModelData>;

我们的服务将提供一个模型,该模型创建一个PinModelData数组的可观察对象,该数组与pins属性挂钩。 我们将所有这些连接在一起的方法是在接收AngularFirestore的构造函数中。 集合通过传入将存储在数据库中的集合名称(将数据保存为 JSON 格式的文档)与底层集合相关联。 我们的ObservablevalueChanges上的收藏如下:

constructor(private readonly db: AngularFirestore) { 
  this.pins = db.collection<PinModelData>('pins');
  this.model = this.pins.valueChanges();
}

在设计这个应用时,我做出的一个决定是,从 UI 中删除一个大头针应该导致从数据库中删除相关的感兴趣点。 因为它没有被其他任何东西引用,所以我们不需要将它作为引用数据保存。 删除数据很简单,使用doc从数据存储中获取基础文档记录使用storageId,然后删除它:

Delete(item: PinModelData) {
  this.pins.doc(item.storageId).delete();
}

当用户添加感兴趣的点时,我们希望在数据库中创建相应的条目,但当用户移动大头针时,我们希望更新记录。 我们可以将逻辑组合成一个方法,而不是使用单独的AddUpdate方法,因为我们知道具有空storageId的记录以前没有被保存到数据库中。 因此,我们使用 FirebasecreateId方法给它一个唯一的 ID。 如果有storageId,那么我们想更新它:

Save(item: PinModelData) {
  if (item.storageId === '') {
    item.storageId = this.db.createId();
    this.pins.doc(item.storageId).set(item);
  }
  else {
    this.pins.doc(item.storageId).update(item);
  }
}

代表地图插销

我们可以将大头针保存到数据库中,但我们还需要一种方法在地图上表示大头针,以便在地图会话期间显示它们,并根据需要移动它们。 这个类也将充当到数据服务的连接。 我们将要编写的类将演示 TypeScript 3 中引入的一个整洁的小技巧,称为rest 元组,它的开头如下:

export class PinsModel {
  private pins: PinModelData[] = [];
  constructor(private firebaseMapService: FirebaseMapService) { }
}

我们要介绍的第一个特性是在用户单击地图时为大头针添加数据。 这个方法的签名看起来有点奇怪,所以我们将花一分钟左右来介绍它是如何工作的。 这是签名的样子:

public Add(...args: [string, string, ...number[]]);

当我们看到...args作为最后(或唯一)参数时,我们立即想到的是我们将使用一个 REST 参数。 如果我们从一开始就分解参数列表,我们可以这样想:

public Add(arg_1: string, arg_2: string, ...number[]);

这看起来似乎是有意义的,但这里还有另一个 REST 参数。 这基本上是说,我们可以在元组的末尾有任意数量的数字。 我们之所以要应用...而不仅仅是number[],是因为我们需要将元素展开。 如果仅使用数组格式,则必须在调用代码中将元素推入该数组。 使用元组中的 REST 参数,我们可以取出数据,将其保存到数据库中,并将其添加到我们的pins数组中,如下所示:

public Add(...args: [string, string, ...number[]]) {
  const data: PinModelData = {
    id: args[0],
    name: args[1],
    lat: args[2],
    long: args[3],
    storageId: ''
  };
  this.firebaseMapService.Save(data);
  this.pins.push(data);
}

The implication of using a tuple like this is that the calling code has to make sure that it is putting values into the correct location.

当我们得到调用这个的代码时,我们可以看到我们的方法被调用如下:

this.pinsModel.Add(guid.toString(), geocode, e.location.latitude, e.location.longitude);

当用户移动地图上的大头针时,我们将使用类似的技巧来更新它的位置。 我们需要做的就是在数组中找到模型并更新它的值。 我们甚至必须更新名称,因为移动大头针的动作将改变大头针的地址。 我们在我们的数据服务上调用相同的Save方法,就像我们在Add方法中所做的那样:

public Move(...args: [string,string, ...number[]]) {
  const pinModel: PinModelData = this.pins.find(x => x.id === args[0]);
  if (pinModel) {
    pinModel.name = args[1];
    pinModel.lat = args[2];
    pinModel.long = args[3];
  }
  this.firebaseMapService.Save(pinModel);
}

其他类也需要访问数据库中的数据。 这里我们面临两种选择—我们可以让其他类也使用 Firebase 映射服务并可能错过对该类的调用,或者我们可以让该类成为唯一访问映射服务的点。 我们将依赖这个类作为与FirebaseMapPinsService的单点接触,这意味着我们需要通过Load方法暴露model:

public Load(): Observable<PinModelData[]>{
  return this.firebaseMapService.model;
}

删除感兴趣的点使用比添加或移动一个点简单得多的方法签名。 我们所需要的是客户端记录的id,我们使用它来查找PinModelData项并调用Delete从 Firebase 中删除。 一旦我们删除了该记录,我们就找到该记录的本地索引,并通过拼接数组来删除它:

public Remove(id: string) {
  const pinModel: PinModelData = this.pins.find(x => x.id === id);
  this.firebaseMapService.Delete(pinModel);
  const index: number = this.pins.findIndex(x => x.id === id);
  if (index >= 0) {
    this.pins.splice(index,1);
  }
}

尝试用地图搜索做一些有趣的事情

当涉及到获取用户放置或移动 pin 的位置的名称时,我们希望这能自动发生。 我们真的不希望用户输入这个值当映射可以自动为我们选择这个。 这意味着我们必须使用映射特性来获取这些信息。

必应地图有许多可选模块,我们可以选择使用这些模块,使我们能够根据位置进行搜索等操作。 为了做到这一点,我们将创建一个名为MapGeocode的类,它将为我们进行搜索:

export class MapGeocode {
}

您可能会注意到,对于我们的一些类,我们是在创建它们而不是创建服务。 这意味着我们必须自己手动实例化类。 这很好,因为我们可以手动控制类的生存期。 如果愿意,在重新创建代码时,可以将MapGeocode等类转换为服务并注入它。

由于搜索是一个可选的功能,我们需要加载它。 为了做到这一点,我们将传入我们的 map,并使用loadModule来加载Microsoft.Maps.Search模块,传入一个新的SearchManager实例作为选项:

private searchManager: Microsoft.Maps.Search.SearchManager;
constructor(private map: Microsoft.Maps.Map) {
  Microsoft.Maps.loadModule('Microsoft.Maps.Search', () => {
    this.searchManager = new Microsoft.Maps.Search.SearchManager(this.map);
  });
}

剩下要做的就是编写一个方法来执行查找。 由于这可能是一个冗长的操作,我们需要将其设置为Promise类型,返回将使用名称填充的字符串。 在这个Promise中,我们创建一个包含位置和回调的请求,当被reverseGeocode方法执行时,将用位置的名称更新Promise中的回调。 有了这个,我们调用searchManager.reverseGeocode来执行搜索:

public ReverseGeocode(location: Microsoft.Maps.Location): Promise<string> {
  return new Promise<string>((callback) => {
    const request = {
      location: location,
      callback: function (code) { callback(code.name); }
    };
    if (this.searchManager) {
      this.searchManager.reverseGeocode(request);
    }
  });
}

Names matter in coding. In mapping, when we geocode, we convert a physical address into a location. The act of converting a location into an address is called reverse geocoding. That is why our method has the rather cumbersome name of ReverseGeocode.

我们还需要考虑另一种类型的搜索。 我们希望搜索使用可见地图区域(视口)来识别该区域中的咖啡店。 为此,我们将使用微软新的 Local Insights API 来搜索特定区域的业务等内容。 目前该功能的实现存在局限性,因为 Local Insights 仅适用于美国地址,但计划将该功能推广到其他国家和大陆。

为了表明我们仍然可以在服务中使用映射,我们将创建一个接受HttpClientPointsOfInterestService,我们将使用它来获得 REST 调用的结果:

export class PointsOfInterestService {
  constructor(private http: HttpClient) {}
}

REST 调用端点接受一个查询,该查询告诉我们对什么类型的企业感兴趣、执行搜索所使用的位置和地图键。 再次,我们的搜索功能可能是长期运行的,所以我们将返回一个Promise,这次是自定义的PoiPoint,它返回纬度和经度,以及企业名称:

export interface PoiPoint {
  lat: number,
  long: number,
  name: string
}

当我们调用 API 时,我们将使用http.get,它将返回一个可观察对象。 我们将用MapData对结果进行pipe运算,然后用MapData对结果进行map运算。 我们将subscribe写入结果并解析结果(注意,我们并不真正知道返回类型,因此我们将其保留为any)。 返回类型可以包含多个resourceSets,大多数情况下,如果我们一次有多个查询类型,但我们只需要关注初始的resourceSet,然后我们将使用它来提取资源。 下面的代码显示了这次搜索中我们感兴趣的元素的格式。 当我们完成解析我们的结果时,我们将从搜索订阅中取消订阅,并使用我们刚刚添加的点回调Promise:

public Search(location: location): Promise<PoiPoint[]> {
  const endpoint = `https://dev.virtualearth.net/REST/v1/LocalSearch/?query=coffee&userLocation=${location[0]},${location[1]}&key=${environment.mapKey}`;
  return new Promise<PoiPoint[]>((callback) => {
    const subscription: Subscription = this.http.get(endpoint).pipe(map(this.MapData))
    .subscribe((x: any) => {
      const points: PoiPoint[] = [];
      if (x.resourceSets && x.resourceSets.length > 0 && x.resourceSets[0].resources) {
        x.resourceSets[0].resources.forEach(element => {
          if (element.geocodePoints && element.geocodePoints.length > 0) {
            const poi: PoiPoint = {
              lat: element.geocodePoints[0].coordinates[0],
              long: element.geocodePoints[0].coordinates[1],
              name: element.name
            };
            points.push(poi)
          }
        });
      }
      subscription.unsubscribe();
      callback(points);
    })
  });
}

In our query, we are simply going to search at a point—we can easily extend this to search in a bounding box restricted to our view, if we wanted to, by accepting the map bounding box and changing userLocation to userMapView=${boundingBox{0}},${boundingBox{1}},${boundingBox{2}},${boundingBox{3}} (where boundingBox is a rectangle). For further details about extending the search, see https://docs.microsoft.com/en-us/previous-versions/mt832854(v=msdn.10).

现在我们已经完成了地图搜索功能和数据库功能,把地图放到屏幕上不是很好吗? 现在我们来解决这个问题。

在屏幕上添加必应地图

与前面介绍的一样,我们将使用两个组件来显示映射。 让我们从MapViewComponent开始。 这个控件的 HTML 模板非常简单:

<div #myMap style='width: 100%; height: 100%;'>
</div> 

是的,这就是我们 HTML 的全部内容。 它背后发生的事情有点复杂,我们将在这里学习 Angular 如何让我们连接到标准 DOM 事件。 我们通常不会显示整个@Component元素,因为它几乎是样板代码,但在这种情况下,我们将不得不做一些不同的事情。 这是我们组件的第一部分:

@Component({
  selector: 'atp-map-view',
  templateUrl: './map-view.component.html',
  styleUrls: ['./map-view.component.scss'],
  host: {
    '(window:load)' : 'Loaded()'
  }
})
export class MapViewComponent implements OnInit {
  @ViewChild('myMap') myMap: { nativeElement: string | HTMLElement; };

  constructor() { }

  ngOnInit() {
  }
}

@Component部分,我们将窗口加载事件与Loaded方法挂钩。 稍后我们将添加这个方法,但现在,重要的是要知道,这是我们如何将组件连接到来自宿主的事件。 在组件内部,我们使用一个@ViewChild来连接模板中的div。 基本上,这允许我们通过名称来引用视图中的元素,这样我们就可以以某种任意的方式来处理它。

我们添加Loaded方法的原因是,除非我们在window.load事件中连接地图,否则必应地图在 Chrome 或 Firefox 等浏览器中无法正常工作。 我们将在模板中添加的div语句中托管地图,该语句使用了一系列地图加载选项,其中包括地图凭证和默认缩放级别:

Loaded() {
  // Bing has a nasty habit of not working properly in browsers like 
  // Chrome if we don't hook the map up 
  // in the window.load event.
  const map = new Microsoft.Maps.Map(this.myMap.nativeElement, {
    credentials: environment.mapKey,
    enableCORS: true,
    zoom: 13
  });
  this.map.emit(map);
}

如果我们想要选择一个特定类型的地图类型来显示,我们可以在地图加载选项中设置如下:

mapTypeId:Microsoft.Maps.MapTypeId.road

我们的MapViewComponent将被托管在另一个组件中,所以我们将创建一个EventEmitter,我们可以用它来通知父组件。 我们已经在Loaded方法中添加了 emit 代码,将刚刚加载的 map 传递回父函数:

@Output() map = new EventEmitter();

现在让我们添加父容器。 模板的大部分内容只是创建带有行和列的 Bootstrap 容器。 在div列中,我们将托管刚刚创建的子组件。 同样,我们可以看到我们使用了EventEmitter,所以当映射被触发时,它会触发MapLoaded事件:

<div class="container-fluid h-100">
  <div class="row h-100">
    <div class="col-12">
      <atp-map-view (map)="MapLoaded($event)"></atp-map-view>
    </div>
  </div>
</div>

到目前为止,大多数映射容器代码对我们来说应该是熟悉的领域。 我们注入FirebaseMapPinsServicePointsOfInterestService,用来在MapLoaded方法中创建MapEvents实例。 换句话说,当atp-map-view组件点击window.load时,填充的 Bing 地图返回:

export class MappingcontainerComponent implements OnInit {
  private map: Microsoft.Maps.Map;
  private mapEvents: MapEvents;
  constructor(private readonly firebaseMapPinService: FirebaseMapPinsService, 
              private readonly poi: PointsOfInterestService) { }

  ngOnInit() {
  }

  MapLoaded(map: Microsoft.Maps.Map) {
    this.map = map;
    this.mapEvents = new MapEvents(this.map, new PinsModel(this.firebaseMapPinService), this.poi);
  }
}

关于显示地图的注意事项——我们真的需要设置htmlbody的高度,这样它就可以延伸到浏览器窗口的全部高度。 在styles.scss文件中设置,如下所示:

html,body {
  height: 100%;
}

地图事件和设置图钉

我们有地图,我们有逻辑将感兴趣的点保存到数据库并将它们移动到内存中。 我们没有代码来处理用户实际创建和管理来自映射本身的大头针。 现在是时候纠正这种情况,并添加一个MapEvents类来为我们处理这个问题。 就像MapGeocodePinModelPinsModel类一样,这个类是一个独立的实现。 让我们从添加以下代码开始:

export class MapEvents {
  private readonly geocode: MapGeocode;
  private infoBox: Microsoft.Maps.Infobox;

  constructor(private map: Microsoft.Maps.Map, private pinsModel: PinsModel, private poi: PointsOfInterestService) {

  }
}

Infobox是当我们在屏幕上添加一个感兴趣的点时出现的框。 我们可以在添加每个兴趣点时添加一个新的兴趣点,但这将浪费资源。 相反,我们将添加一个单独的Infobox,并在屏幕上添加新点时重用它。 为此,我们将添加一个 helper 方法,用于检查之前是否设置了Infobox。 如果它之前没有被设置,我们将实例化一个新的Infobox实例,包括引脚位置、标题和描述。 我们将提供点的名称作为描述。 我们需要使用setMap设置将显示的映射实例。 当我们重用这个Infobox时,我们所需要做的就是在选项中设置相同的值,然后将可见性设置为true:

private SetInfoBox(title: string, description: string, pin: Microsoft.Maps.Pushpin): void {
  if (!this.infoBox) {
    this.infoBox = new Microsoft.Maps.Infobox(pin.getLocation(), { title: title, description: description });
    this.infoBox.setMap(this.map);
  return;
  }
  this.infoBox.setOptions({
    title: title,
    description: description,
    location: pin.getLocation(),
    visible: true
  });
}

在添加从地图中选择点的功能之前,我们还需要向这个类添加几个助手方法。 我们要添加的第一个方法是从 Local Insights 搜索中获取兴趣点,并将它们添加到地图中。 这里,我们可以看到添加大头针的方法是创建一个绿色的Pushpin,然后在正确的Location处添加到 Bing 地图上。 我们还添加了一个事件处理程序,它对大头针上的点击做出反应,并使用我们刚刚添加的方法显示Infobox:

AddPoi(pois: PoiPoint[]): void {
  pois.forEach(poi => {
    const pin: Microsoft.Maps.Pushpin = new Microsoft.Maps.Pushpin(new Microsoft.Maps.Location(poi.lat, poi.long), {
      color: Microsoft.Maps.Color.fromHex('#00ff00')
    });
    this.map.entities.push(pin);
    Microsoft.Maps.Events.addHandler(pin, 'click', (x) => {
      this.SetInfoBox('Point of interest', poi.name, pin);
    });
  })
}

下一个 helper 方法更复杂,所以我们将分阶段添加它。 当用户点击地图时,将调用AddPushPin代码。 签名如下:

AddPushPin(e: any): void {
}

在这个方法中,我们要做的第一件事是创建一个Guid来使用,当我们添加一个PinsModel条目,并在点击位置添加一个可拖动的Pushpin:

const guid: Guid = Guid.create();
const pin: Microsoft.Maps.Pushpin = new Microsoft.Maps.Pushpin(e.location, {
  draggable: true
});

在适当的位置,我们将调用我们之前编写的ReverseGeocode方法。 当我们从中得到结果时,我们将添加PinsModel条目,并在显示Infobox之前将Pushpin推到地图上:

this.geocode.GeoCode(e.location).then((geocode) => {
  this.pinsModel.Add(guid.toString(), geocode, e.location.latitude, e.location.longitude);
  this.map.entities.push(pin);
  this.SetInfoBox('User location', geocode, pin);
});

我们还没有完成这个方法。 除了添加一个Pushpin,我们还必须能够拖动它,以便用户可以选择一个新的位置,当他们拖动大头针。 我们将使用dragend事件来移动大头针。 再一次,我们早先付出的努力是有回报的,因为我们有一个简单的机制来Move``PinsModelInfobox:

const dragHandler = Microsoft.Maps.Events.addHandler(pin, 'dragend', (args: any) => {
  this.geocode.GeoCode(args.location).then((geocode) => {
    this.pinsModel.Move(guid.toString(), geocode, args.location.latitude, args.location.longitude);
    this.SetInfoBox('User location (Moved)', geocode, pin);
  });
});

最后,当用户单击大头针时,我们希望从PinsModel和地图中删除大头针。 当我们为dragendclick添加事件处理程序时,我们将处理程序保存到变量中,以便我们可以使用它们从映射事件中删除事件处理程序。 在我们自己之后整理是很好的做法,特别是在处理事件处理程序时:

const handler = Microsoft.Maps.Events.addHandler(pin, 'click', () => {
  this.pinsModel.Remove(guid.toString());
  this.map.entities.remove(pin);

  // Tidy up our stray event handlers.
  Microsoft.Maps.Events.removeHandler(handler);
  Microsoft.Maps.Events.removeHandler(dragHandler);
});

这就是我们的辅助方法。 我们现在需要做的就是更新构造函数,将功能添加到地图上的click,以设置感兴趣的点,并在用户查看更改的视口时搜索 Local Insights。 让我们从回应用户点击地图开始:

this.geocode = new MapGeocode(this.map);
Microsoft.Maps.Events.addHandler(map, 'click', (e: any) => {
  this.AddPushPin(e);
});

We don't need to store the handler as a variable here because we are associating it with something that won't be removed at any stage while the application is live in the browser; namely, the map itself.

当用户移动地图以便查看其他区域时,我们需要执行 Local Insights 搜索,并根据返回的结果添加兴趣点。 我们给 mapviewchangeend事件附加一个事件处理程序来触发这个搜索:

Microsoft.Maps.Events.addHandler(map, 'viewchangeend', () => {
  const center = map.getCenter();
  this.poi.Search([center.latitude, center.longitude]).then(pointsOfInterest => {
    if (pointsOfInterest && pointsOfInterest.length > 0) {
      this.AddPoi(pointsOfInterest);
    }
  })
})

我们不断看到,事先准备好方法可以为我们以后节省很多时间。 我们只是利用PointsOfInterestService.Search方法为我们做本地洞察搜索,然后如果我们得到任何回报,将结果注入到我们的AddPoi方法中。 如果我们不想执行 Local Insights 搜索,我们可以简单地删除这个事件处理程序,而不需要进行任何搜索。

我们剩下要做的唯一一件事就是处理从数据库加载我们的大头针。 这里的代码是我们已经看到的添加clickdragend处理程序的代码的变体,但我们不需要执行地理编码,因为我们已经有了每个感兴趣点的名称。 因此,我们不打算重用AddPushPin方法。 相反,我们将选择内联执行整个部分。 加载订阅如下所示:

const subscription = this.pinsModel.Load().subscribe((data: PinModelData[]) => {
  data.forEach(pinData => {
    const pin: Microsoft.Maps.Pushpin = new Microsoft.Maps.Pushpin(new Microsoft.Maps.Location(pinData.lat, pinData.long), {
      draggable: true
    });
    this.map.entities.push(pin);
    const handler = Microsoft.Maps.Events.addHandler(pin, 'click', () => {
      this.pinsModel.Remove(pinData.id);
      this.map.entities.remove(pin);
    Microsoft.Maps.Events.removeHandler(handler);
      Microsoft.Maps.Events.removeHandler(dragHandler);
    });
    const dragHandler = Microsoft.Maps.Events.addHandler(pin, 'dragend', (args: any) => {
      this.geocode.GeoCode(args.location).then((geocode) => {
        this.pinsModel.Move(pinData.id, geocode, args.location.latitude, args.location.longitude);
        this.map.entities.push(pin);
    this.SetInfoBox('User location (moved)', geocode, pin);
      });
    });
  });
  subscription.unsubscribe();
  this.pinsModel.AddFromStore(data);
});

这段代码需要注意的一点是,由于我们正在处理一个订阅,一旦我们完成了订阅,我们就从它unsubscribe开始。 订阅应该返回一个包含PinModelData项的数组,我们将遍历该数组,并根据需要添加元素。

就是这样。 我们现在有了一个有效的映射解决方案。 这是我最期待的章节之一,因为我喜欢映射应用。 我希望你能和我一样开心。 但是,在我们结束本章之前,如果您想防止人们不安全地访问数据,可以在下一节中应用这些知识。

确保数据库

本节是一个可选的概述,介绍为数据库提供安全性需要做些什么。 您可能还记得,在创建 Firestore 数据库时,我们将其设置为任何人都可以访问,完全不受限制。 当您正在开发一个小型测试应用时,这是可以的,但它通常不是您想要作为商业应用部署的。

我们将更改数据库的配置,以便仅在设置了授权 ID 时才允许读/写访问。 为此,选择数据库中的 Rules 选项卡并将if request.auth.uid != null;添加到规则列表中。 match /{document=**}的格式意味着该规则适用于列表中的任何文档。 可以设置只适用于特定文档的规则,但在这样的应用上下文中没有太大意义。

注意,这样做意味着我们必须添加身份验证,就像我们在第 6 章中所做的那样。 。 设置这个超出了本章的范围,但是复制导航和提供前一章的登录功能应该很简单:

这是一段漫长的旅程。 我们经历了注册不同的在线服务的过程,并在代码中引入了映射功能。 同时,我们也看到了如何在不生成和注册服务的情况下,用 TypeScript 支持 Angular 应用。 现在,您应该能够使用这段代码并添加您真正想要的映射特性。

总结

在这一章中,我们已经得出了与 Angular 相关的项目的结论,我们通过 Bing Maps 和 Firebase 云服务的形式介绍了使用微软和谷歌的云服务来存储数据。 我们注册了这些服务,并从它们那里获得了相关信息,以便建立客户对它们的访问。 在编写代码的过程中,我们已经创建了类与 Firestore 数据库和与必应地图做事情,比如基于用户点击搜索地址,导致我们添加到地图上的标记,以及寻找咖啡店使用本地的见解。

继续我们的 TypeScript 之旅,我们引入了 rest 元组。 我们还看到了如何向 Angular 组件中添加代码来响应浏览器的宿主事件。

在下一章中,我们将重新讨论 React。 这一次,我们将创建一个有限的微服务 CRM,使用 Docker 来包含各种微服务。

问题

  1. Angular 是如何让我们与宿主元素交互的?
  2. 纬度和经度是什么?
  3. 反向地理编码的目的是什么?
  4. 我们使用什么服务来存储数据?