Lesson 9: Map
OpenStreetMap
Google Map
啟用Google Maps Platform取得金鑰
建立Google Cloud專案
提供帳單資訊 (信用卡)
收費機制
使用 @ionic-native/google-maps
cordova-plugin-googlemaps
不需金鑰
Free to charge
使用 leaflet
@asymmetrik/ngx-leaflet
@types/leaflet
使用外掛
前置準備
或 直接使用Maps JavaScript API
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)
❶ 建立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
❸ 若地圖放在app.component: 在app.module.ts引入模組設定
...
import { LeafletModule } from '@asymmetrik/ngx-leaflet';
@NgModule({
...
imports: [
...,
LeafletModule
],
...
})
export class AppModule { }
注意: 所有使用地圖頁面的 .module.ts檔,也要分別引入
app.module.ts
❹ 在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
❺ 在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
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
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
❶ 定義資料模型
❷ 地圖需要經緯度座標
export interface Place {
pid: string; // id
title: string; // 景點名稱
location: {
lat: number; // 緯度
lng: number; // 經度
};
address?: string; // 地址
intro?: string; // 景點介紹
}
model/place.ts(未完)
?代表optional(可有可無)
Ⓐ 資料模型
interface vs. class
❶ 定義資料模型
❷ 地圖需要經緯度座標
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(續)
❸ 準備圖資
暫時使用(實際應從資料庫來)
Ⓐ 資料模型
景點資料來源:臺北市臺北旅遊網-景點資料(中文)
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(續)
❸ 準備圖資
暫時使用(實際應從資料庫來)
Ⓐ 資料模型
景點資料來源:臺北市臺北旅遊網-景點資料(中文)
import { Place } from './place';
export interface PlaceDAO {
getPlaces(): Place[];
getPlace(id: string): Place;
}
model/placeDAO.ts
❸ 資料介面檔
Ⓐ 資料模型
Ⓑ 資料服務
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
景點資料讀取服務
自動完成服務
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檔
Ⓒ編輯主頁
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加入服務引數
<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
Ⓒ編輯主頁
ion-toolbar: 參考Ionic 頁面結構
ion-list: 參考Ionic List 元件
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
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() : 使用元件時的輸入參數
<div id="osmap" class="osmap"
leaflet [leafletOptions]="options"
(leafletMapReady)="onMapReady($event)"
>
osmap.component.html
.osmap {
width: 100%;
}
osmap.component.scss
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
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
<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
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
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
路徑規劃
<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
路徑規劃