Ionic Tutorial

Lesson 9: Map

Outline

  • 關於地圖 Open Street Map vs. Google Map
  • 使用Open Street Map

OpenStreetMap

Google Map

地圖 OS map vs. Google map (1/2)

啟用Google Maps Platform取得金鑰
建立Google Cloud專案
提供帳單資訊 (信用卡)
收費機制

使用  @ionic-native/google-maps

cordova-plugin-googlemaps

不需金鑰

Free to charge

使用 leaflet

@asymmetrik/ngx-leaflet

@types/leaflet

使用外掛

前置準備

或   直接使用Maps JavaScript API

地圖 Google Map實作選項 (2/2)

Cordova外掛

Maps JavaScript API

任何平台皆可使用

但都無法離線使用

ionic cordova plugin add cordova-plugin-googlemaps
npm install --save @ionic-native/core@latest
npm install --save @ionic-native/google-maps@latest
<head>
...
<script src="http://maps.google.com/maps/api/js?key=你的金鑰"></script>
<link rel="icon" type="image/png" href="assets/icon/favicon.png" />
</head>

直接修改src/index.html

不同平台各自有外掛

iOS/Android版可離線使用

Cordova外掛 (gmap)

Maps JavaScript API (gmap)

Leaflet (OSMap)

使用OS Map專案準備工作(1/4)

❶ 建立Ionic 專案

ionic start OsMapApp tabs
cd OsMapApp
ionic serve

❷加入leaflet相關套件

npm install leaflet
npm install @asymmetrik/ngx-leaflet
npm install @types/leaflet

JavaScript library
for mobile-friendly interactive maps

ngx-leaflet

Leaflet package for Angular.io

2020/6/3

leaflet@1.6.0
@asymmetrik/ngx-leaflet@7.0.0
@types/leaflet@1.5.12

types-leaflet

Type definition of Leaflet

使用OS Map專案準備工作(2/4)

❸ 若地圖放在app.component: 在app.module.ts引入模組設定

...
import { LeafletModule } from '@asymmetrik/ngx-leaflet';

@NgModule({
  ...
  imports: [
    ...,
    LeafletModule
  ],
  ...
})
export class AppModule { }

注意: 所有使用地圖頁面的 .module.ts檔,也要分別引入

app.module.ts

使用OS Map專案準備工作(3/4)

❹ 在angular.json加入leaflet.css設定

...
"styles": [{
    "input": "src/theme/variables.scss"
  },
  {
    "input": "src/global.scss"
  },
  "./node_modules/leaflet/dist/leaflet.css"
],
...

找到"styles"段落

加入leaflet.css

angular.json

使用OS Map專案準備工作(4/4)

❺ 在angular.json加入讀取圖檔的路徑

"assets": [{
    "glob": "**/*",
    "input": "./node_modules/leaflet/dist/images",
    "output": "./assets"
  },
  "src/assets",
  "src/favicon.ico",
  {
    "glob": "**/*.svg",
    "input": "node_modules/@ionic/angular/dist/ionic/svg",
    "output": "./svg"
  }
],

找到"assets"段落

修改成leaflet路徑

加入路徑與圖檔

angular.json

使用OS Map頁面與服務(1/18)

ionic g service services/data
ionic g page detail
ionic g module shared
ionic g component shared/osmap --export

主頁(Tab1Page)

細節頁面

建立資料服務 

Ⓓ 新增細節頁面

建立資料模型

新增資料夾 models
新增檔案 place.ts

Ⓔ 建立地圖元件

 路徑規劃(Tab2Page)

Ⓖ 搜尋服務

npm i leaflet-routing-machine
npm i --save-dev @types/leaflet-routing-machine
npm install ionic4-auto-complete --save

npm i -g cordova
npm i cordova-plugin-geolocation --legacy-peer-deps
npm install @ionic-native/geolocation  --legacy-peer-deps

Ⓖ 搜尋服務

 路徑規劃

 GPS外掛: 抓取目前位置
(需在手機上執行)

在angular.json加入leaflet-routing-machine.css設定(路徑的圖層)

...
"styles": [
      {
        "input": "src/theme/variables.scss"
      },
      {
        "input": "src/global.scss"
      },
      "./node_modules/leaflet/dist/leaflet.css",
      "./node_modules/leaflet-routing-machine/dist/leaflet-routing-machine.css",
],
...

找到"styles"段落

加入leaflet-routing-machine.css

angular.json

在angular.json加入讀取圖檔的路徑(搜尋自動完成功能)

"assets": [
  ....
  {
    "glob": "**/*",
    "input": "node_modules/ionic4-auto-complete/assets/",
    "output": "./assets/"
  },
  ....
],

angular.json

使用OS Map頁面與服務(2/18)

export class Place {
    pid: string;         // id
    title: string;      // 景點名稱
    location: {
        lat: number;    // 緯度
        lng: number;    // 經度
    };
    address?: string;   // 地址
    intro?: string;     // 景點介紹

    constructor(pid: string, title: string, loc: any,
                addr?: string, intro?: string) {
        this.pid = pid;
        this.title = title;
        if (addr) { this.address = addr; }
        this.location = loc;
        if (intro) { this.intro = intro; }
    }
}

 model/place.ts(未完)

?代表optional(可有可無)

  資料模型

interface vs. class

❶ 定義資料模型

地圖需要經緯度座標

Interface版(1/2)

export interface Place {
    pid: string;        // id
    title: string;      // 景點名稱
    location: {
        lat: number;    // 緯度
        lng: number;    // 經度
    };
    address?: string;   // 地址
    intro?: string;     // 景點介紹
}

 model/place.ts(未完)

?代表optional(可有可無)

  資料模型

interface vs. class

❶ 定義資料模型

地圖需要經緯度座標

使用OS Map頁面與服務(3/18)

export class Place {
  ...
}
  
export const DUMMY_PLACES: Place[] = [
    new Place('10001', '捷運北門站(台北鐵道局)', {lat: 25.049556, lng: 121.510181},
    '臺北市  大同區塔城街10號',
    '捷運北門站為捷運松山線,位於塔城街,為一地下4層車站,長約171公尺、寬約32公尺,開挖深度約32公尺,設有3個出入口、2座通風井及2座無障礙電梯。'),
    new Place('10002', '幾米主題南港站', {lat: 25.051801, lng: 121.606045},
    '臺北市  南港區忠孝東路7段380號',
    '受到各方廣大喜愛的幾米主題裝置藝術,就藏身在捷運板南線的南港站之中。南港捷運站大膽引進享譽國際的幾米繪畫,完整表現出捷運站的現代性,既寫實又富美感。'),
    new Place('10003', '140高地公園', {lat: 25.004011, lng: 121.568527},
    '臺北市  文山區萬寧街125號',
    '140高地公園位於台北市文山區萬美里境內,萬寧街北側,介於萬寧街與萬美街2段間的丘陵地,最高海拔為138公尺的枹子腳山。'),
    new Place('10004', '通化公園', {lat: 25.032323, lng: 121.560089},
    '臺北市  信義區文昌街與通化街口',
    '本公園位於文昌街與通化街口,於民國76年建立,原命名為「文通公園」,面積1,180平方公尺,後改「文通公園」為「通化公園」。'),
    new Place('10005', '世貿公園', {lat: 25.058038, lng: 121.615468},
    '臺北市  南港區經貿二路106巷',
    '為提供市民更多的停車空間,停管處新建南港區世貿公園地下停車場,並於民國100年簡易綠美化,公園面積約1.2公頃,位於南港區經貿二路106巷。'),
];

model/place.ts(續)

❸ 準備圖資

暫時使用(實際應從資料庫來)

  資料模型

Interface版(2/2)

export interface Place {
...
}
export const DUMMY_PLACES: Place[] = [
    {
        pid: '10001', title: '捷運北門站(台北鐵道局)', location: { lat: 25.049556, lng: 121.510181 },
        address: '臺北市  大同區塔城街10號',
        intro: '捷運北門站為捷運松山線,位於塔城街,為一地下4層車站,長約171公尺、寬約32公尺,開挖深度約32公尺,設有3個出入口、2座通風井及2座無障礙電梯。'
    },
    {
        pid: '10002', title: '幾米主題南港站', location: { lat: 25.051801, lng: 121.606045 },
        address: '臺北市  南港區忠孝東路7段380號',
        intro: '受到各方廣大喜愛的幾米主題裝置藝術,就藏身在捷運板南線的南港站之中。南港捷運站大膽引進享譽國際的幾米繪畫,完整表現出捷運站的現代性,既寫實又富美感。'
    },
    {
        pid: '10003', title: '140高地公園', location: { lat: 25.004011, lng: 121.568527 },
        address: '臺北市  文山區萬寧街125號',
        intro: '140高地公園位於台北市文山區萬美里境內,萬寧街北側,介於萬寧街與萬美街2段間的丘陵地,最高海拔為138公尺的枹子腳山。'
    },
    {
        pid: '10004', title: '通化公園', location: { lat: 25.032323, lng: 121.560089 },
        address: '臺北市  信義區文昌街與通化街口',
        intro: '本公園位於文昌街與通化街口,於民國76年建立,原命名為「文通公園」,面積1,180平方公尺,後改「文通公園」為「通化公園」。'
    },
    {
        pid: '10005', title: '世貿公園', location: { lat: 25.058038, lng: 121.615468 },
        address: '臺北市  南港區經貿二路106巷',
        intro: '為提供市民更多的停車空間,停管處新建南港區世貿公園地下停車場,並於民國100年簡易綠美化,公園面積約1.2公頃,位於南港區經貿二路106巷。'
    }
];

model/place.ts(續)

❸ 準備圖資

暫時使用(實際應從資料庫來)

  資料模型

使用OS Map頁面與服務(4/18)

import { Place } from './place';

export interface PlaceDAO {
    getPlaces(): Place[];
    getPlace(id: string): Place;
}

model/placeDAO.ts

❸ 資料介面檔

  資料模型

使用OS Map頁面與服務(5/18)

  資料服務

import { DUMMY_PLACES, Place } from '../models/place';
import { Injectable } from '@angular/core';
import { AutoCompleteService } from 'ionic4-auto-complete';
import { PlaceDAO } from '../model/placeDAO';

@Injectable({
  providedIn: 'root'
})
export class DataService implements PlaceDAO, AutoCompleteService{
  places = DUMMY_PLACES;
  labelAttribute = 'title'; // 給autoCompleteService用

  constructor() { }

  // 回傳所有景點
  getPlaces(): Place[] {
    return this.places;
  }
  // 回傳單一景點
  getPlace(id: string): Place {
    return this.places.filter(p => p.pid === id)[0]; 
  }
  // 實作 auto complete service
  getResults(term: string) {
    const key = term.toLocaleLowerCase();
    return this.places.filter(place => {
      // 尋找title欄位中有出現key這個子字串的資料
      return place.title.toLocaleLowerCase().indexOf(key) > -1;
    })
  }
}

services/data.service.ts

景點資料讀取服務

自動完成服務

使用OS Map頁面與服務(6/18)

places: Place[] = DUMMY_PLACES;

屬性名稱

 Method名稱

陣列.find(): 搜尋第一個符合條件的元素

getPlace(id: string): Place {
  return this.places.find(p => p.pid === id );
}

型別 (陣列型別)

常數初值

回傳型別

引數(名稱: 型別)

陣列.find((陣列元素引數) => {函數主體});

  資料服務

http://localhost:8200/detail/123

則傳遞方式有2種:

http://localhost:8200/detail; id=123

使用網址路徑

想將 參數對 {id: 123} 傳送到detail頁面

❷ 不改網址, 直接傳參數

修改app-routing.module.ts

頁面以[routerLink]指定網址、參數

處理方式:

const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'full' },
  { path: 'home', loadChildren: './home/home.module#HomePageModule' },
  { path: 'detail/:id', loadChildren: './detail/detail.module#DetailPageModule' },
];

將'detail'改成'detail/:id

app-routing.module.ts 不修改

Ⓑ TS檔呼叫navigate(), 指定網址、參數

處理方式:

import { Router } from '@angular/router';
  ....略....
  constructor(private router: Router) {}
  detail(stockid) {
    this.router.navigate(['detail', {id: stockid}]);
  }
}

使用navigate()

網址

參數對

TS檔

使用OS Map頁面與服務(7/18)

Ⓒ編輯主頁

使用OS Map頁面與服務(8/18)

import { DataService } from './../_service/data.service';
import { Component } from '@angular/core';
import { Place } from '../_models/place';

@Component({
  selector: 'app-tab1',
  templateUrl: 'tab1.page.html',
  styleUrls: ['tab1.page.scss']
})
export class Tab1Page {
  places: Place[];

  constructor(private ds: DataService) {
    this.places = ds.getPlaces();
  }
}

編輯tab1.page.ts

使用服務讀取景點資料

引入服務、型別

景點屬性陣列

tab1.page.ts

Ⓒ編輯主頁

 透過服務讀取陣列

 需在constuctor加入服務引數

使用OS Map頁面與服務(9/18)

<ion-header>
  <ion-toolbar>
    <ion-title>
      Open Street Map
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content padding>
  <ion-list>
    <ion-item *ngFor="let place of places" [routerLink]="['/detail', {id:place.pid} ]">
      <ion-label>{{ place.title }}</ion-label>
    </ion-item>
  </ion-list>
</ion-content>

編輯tab1.page.html

tab1.page.html

Ⓒ編輯主頁

使用OS Map頁面與服務(10/18)

import { LeafletModule } from '@asymmetrik/ngx-leaflet';
import { OsmapComponent } from './osmap/osmap.component';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

@NgModule({
  declarations: [ OsmapComponent ],
  imports: [
    CommonModule,
    LeafletModule
  ],
  exports: [
    OsmapComponent
  ]
})
export class SharedModule { }

編輯shared.module.ts

shared.module.ts

使用OS Map頁面與服務(11/18)

import { Component, OnInit, Input } from '@angular/core';
import * as L from 'leaflet';
import 'leaflet-routing-machine';

@Component({
  selector: 'app-osmap',
  templateUrl: './osmap.component.html',
  styleUrls: ['./osmap.component.scss'],
})
export class OsmapComponent implements OnInit {
  @Input() place?: L.LatLng; // 輸入參數:經緯度
  @Input() height?: string;  // 輸入參數: 地圖的高度

  options: any;
  map: L.Map;
  streetMap: L.TileLayer;
  marker: L.Marker;

  constructor() { }

  ngOnInit() {
    console.log(this.place);
    this.showMap();
  }

  showMap() {
    if (this.place === undefined) {
      this.place = new L.LatLng(25.1769173, 121.4351497);
    }

    if (this.height !== undefined) {
      console.log('高度:', this.height);
      this.setHeight(this.height + 'px');
    }

    this.streetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      attribution: '© OpenStreetMap'
    });
    
    const center = this.place;
    this.marker = L.marker(center, {
      icon: L.icon({
        iconSize: [25, 41],
        iconAnchor: [13, 41],
        iconUrl: 'assets/marker-icon.png',
        shadowUrl: 'assets/marker-shadow.png'
      })
    });
    this.options = {
      layers: [this.streetMap, this.marker],
      zoom: 12,
      center
    };
  }

  onMapReady(map: L.Map) {
    this.map = map;
    console.log('osmap', map);
    setTimeout(() => { this.map.invalidateSize(); }, 100);
  }

  setHeight(height: string) {
    const osmap = document.getElementById('osmap');

    osmap.setAttribute('style', 'width:100%; height:' + height);
  }

  setPlace(pos: L.LatLng) {
    console.log(pos);
    this.marker.setLatLng(pos);
    this.map.panTo(pos);
  }

  getRoute(source: L.LatLng, dest: L.LatLng) {
    L.Routing.control({
      waypoints: [source, dest],
      routeWhileDragging: true,
      show: false,
    }).addTo(this.map);

    // L.Routing.itinerary({pointMarkerStyle: {radius: 5, color: '#03f', fillColor: 'white', opacity: 1, fillOpacity: 0.7}});

  }
}

osmap.component.ts

@Input() : 使用元件時的輸入參數

使用OS Map頁面與服務(12/18)

<div id="osmap" class="osmap" 
leaflet [leafletOptions]="options"
(leafletMapReady)="onMapReady($event)"
>

osmap.component.html

.osmap {
    width: 100%;
}

osmap.component.scss

使用OS Map頁面與服務(13/18)

import { SharedModule } from './../shared/shared.module';
import { LeafletModule } from '@asymmetrik/ngx-leaflet';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Routes, RouterModule } from '@angular/router';

import { IonicModule } from '@ionic/angular';

import { DetailPage } from './detail.page';

const routes: Routes = [
  {
    path: '',
    component: DetailPage
  }
];

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    LeafletModule,
    SharedModule,
    RouterModule.forChild(routes)
  ],
  declarations: [DetailPage]
})
export class DetailPageModule {}

編輯detail.module.ts

使用OS Map頁面與服務(14/18)

import { ActivatedRoute } from '@angular/router';
import { Component, OnInit } from '@angular/core';
import { DataService } from '../services/data.service';
import { Place } from '../models/place';

import * as L from 'leaflet';

@Component({
  selector: 'app-detail',
  templateUrl: './detail.page.html',
  styleUrls: ['./detail.page.scss'],
})
export class DetailPage implements OnInit {
  place: Place;       // 景點資料
  position: L.LatLng; // 景點的經緯度

  constructor(private route: ActivatedRoute, private ds: DataService) {
    const id = this.route.snapshot.paramMap.get('id');    // 接收參數
    this.place = this.ds.getPlace(id);         // 取得景點資料
    // 建立景點的經緯度, 用於HTML頁面當作參數
    this.position = new L.LatLng(this.place.location.lat, this.place.location.lng); 
    console.log(this.place);
   }

  ngOnInit() {
  }

}

編輯detail.page.ts

使用OS Map頁面與服務(15/18)

<ion-header>
  <ion-toolbar>
    <ion-buttons slot="start">
      <ion-back-button></ion-back-button>
    </ion-buttons>
    <ion-title>{{ place.title }}</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content padding>
  <ion-card>
    <ion-card-header>
      <ion-card-subtitle>{{ place.address }}</ion-card-subtitle>
      <ion-card-title>{{ place.title }}</ion-card-title>
    </ion-card-header>
    <ion-card-content>
      <p>{{ place.intro }}</p>
      <app-osmap [place]="position" [height]="'300'"></app-osmap>
    </ion-card-content>
  </ion-card>
</ion-content>

編輯detail.page.html

使用OS Map頁面與服務(16/18)

import { SharedModule } from './../shared/shared.module';
import { LeafletModule } from '@asymmetrik/ngx-leaflet';
import { IonicModule } from '@ionic/angular';
import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Tab2Page } from './tab2.page';
import { AutoCompleteModule } from 'ionic4-auto-complete';

@NgModule({
  imports: [
    IonicModule,
    CommonModule,
    FormsModule,
    ReactiveFormsModule,
    LeafletModule,
    SharedModule,
    AutoCompleteModule,
    RouterModule.forChild([{ path: '', component: Tab2Page }])
  ],
  declarations: [Tab2Page]
})
export class Tab2PageModule {}

編輯tab2.module.ts

路徑規劃

加入三個模組:LeafletModule

SharedModule

AutoCompleteModule

使用OS Map頁面與服務(17/18)

import { Geolocation } from '@ionic-native/geolocation/ngx';
import { OsmapComponent } from '../shared/osmap/osmap.component';
import { Component, ViewChild, AfterContentInit, ElementRef } from '@angular/core';

import * as L from 'leaflet';
import { FormControl } from '@angular/forms';
import { DataService } from '../services/data.service';
import { AutoCompleteOptions } from 'ionic4-auto-complete';
import { Place } from '../models/place';

@Component({
  selector: 'app-tab2',
  templateUrl: 'tab2.page.html',
  styleUrls: ['tab2.page.scss']
})
export class Tab2Page implements AfterContentInit {
  @ViewChild(OsmapComponent) osmap: OsmapComponent;
  @ViewChild('searchbar') searchbar: ElementRef;
  place: L.LatLng;
  height = '100%';
  searchControl: FormControl; // 搜尋框內容
  public selected: any = '';

  // @ts-ignore
  public options: AutoCompleteOptions = {
    autocomplete: 'off',
    debounce: 750,
    placeholder: '搜尋..',
    type: 'search'
  };

  constructor(public ds: DataService, private geo: Geolocation) {
    this.searchControl = new FormControl();
    this.geo.getCurrentPosition((resp) => {
      this.place = new L.LatLng(resp.coords.latitude, resp.coords.longitude);
      console.log('current position:', this.place);
      this.osmap.setPlace(this.place);
    });
  }

  ngAfterContentInit() {
    this.osmap.setHeight(this.height);
  }

  onSearchChange(event: any) {
    console.log(event);
  }

  itemChanged(pos: Place) {
    const dest = new L.LatLng(pos.location.lat, pos.location.lng);
    this.osmap.getRoute(this.place, dest);
  }
}

編輯tab2.page.ts

路徑規劃

使用OS Map頁面與服務(18/18)

<ion-header>
  <ion-toolbar>
    <ion-title>
      路徑規劃
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-grid fixed>
    <ion-row>
      <ion-col size="3">目的地:</ion-col>
      <ion-col size="9">
        <ion-auto-complete [(model)]="selected" [dataProvider]="ds" [options]="options" (itemsChange)="itemChanged($event)">
        </ion-auto-complete>
      </ion-col>
    </ion-row>
  </ion-grid>

  <app-osmap [place]="place"></app-osmap>
</ion-content>

編輯tab2.page.html

路徑規劃