GoogleMap Api 串接

使用 vue - 3.3.4、Element Plus (2.3.14)、UnoCSS

測試用資料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"data": {
"fromAddr": "60096嘉義市西區北港路312號",
"toAddr": "嘉義縣水上鄉608嘉義縣水上鄉柳子林167號",
"fromLng": 120.42946000,
"fromLat": 23.48145300,
"toLng": 120.41777000,
"toLat": 23.43605600,
"distance": 6416,
"duration": 864,
"highSpeedDistance": 0,
"polyLine": "uj`nCwa~}UDJEb@A\\G@G?KAgB{@qAi@w@K{A@i@Jc@QgAa@k@NiAP_BLgFT{DRyKj@_Mj@_EXO@ULYd@{CzIwBxGYv@Mb@qA~EiCyBmHwF}AqAyBaBs@s@_BkAmCaCaF{D}PgNcFcEiSmPyNoLgEgDgA_AoD`FMPOKsB_B_JsHqLyJMKKHc@Xy@ZqFz@gItAgInAy@Ei@QkBcAaF}CyD}BUMKTwAvC{@g@p@uAKIuA{@OIG?k@hAGJ",
"createDate": "2024-05-17 17:44:35",
"createUserId": "0000",
"createUserName": "超級管理員",
"id": "547937785340037"
}
}

頁面範例

/views/reserve.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<section class="app-container">
<el-card shadow="never">
<el-row class="w-full h-full">
<el-col :span="10">
<div id="map" class="w-full h-full"></div>
</el-col>
<el-col :span="14" class="p-20px">
<el-form>
<el-form-item label="上車地址">
<el-select v-model="startPoint" @change="((val: string) => clickOption(val, 'from'))"
:remote-method="((val: string) => autocompleteMethod(val, 'from'))" :default-first-option="false" remote
filterable placeholder="請輸入上車地址" class="w-full">
<el-option v-for="(item, idx) in searchResults" :key="idx" :label="item.description"
:value="item.description">
<span style="float: left">{{ item.description }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">{{ item.name }}</span>
</el-option>
</el-select>
</el-form-item>

<el-form-item label="下車地址">
<el-select v-model="endPoint" @change="((val: string) => clickOption(val, 'to'))"
:remote-method="((val: string) => autocompleteMethod(val, 'to'))" :default-first-option="false" remote
filterable placeholder="請輸入下車地址" class="w-full">
<el-option v-for="(item, idx) in searchResults" :key="idx" :label="item.description"
:value="item.description">
<span style="float: left">{{ item.description }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">{{ item.name }}</span>
</el-option>
</el-select>
</el-form-item>
</el-form>
</el-col>
</el-row>
</el-card>
</section>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
<script lang="ts" setup>
import { useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/store/modules/app';
import { useGoogleMap } from '@/composables/useGoogleMap';
import { SweetAlertResult } from 'sweetalert2';
import { addressHistory } from '@/api/longtermcareorders';

const router = useRouter();
const route = useRoute();
const appStore = useAppStore();

const { autocompeleteService, toTargetPlace, initDirectionsService, useDirectionsService, initAutoComplete, initMap } = useGoogleMap();

/** @const 上車地址 - input */
const startPoint = ref<string>('');
/** @const 下車地址 - input */
const endPoint = ref<string>('');
/** @const 顯示建議結果 */
const searchResults = ref<any[]>([]);
/** @const 下車地址建議結果 */
const toFavoriteAddr = ref<object[]>([]);
/** @const 上車地址建議結果 */
const fromFavoriteAddr = ref<object[]>([]);

/** @const 目前模式(from - 上車/ to - 下車) */
const addrType = ref<string>('');
/** @const 是否顯示建議地址 */
const isShowsuggest = ref<boolean>(false);
/** @const 是否填完上下車地址 */
const isDone = ref<boolean>(false);

/** @func 建議選項方法 */
function autocompleteMethod(query: string, type: string) {
if (query === '') return;
isDone.value = false;
if (!autocompeleteService.value) return;
const obj = { input: query, componentRestrictions: { country: 'tw' } };
autocompeleteService.value.getPlacePredictions(obj, displaySuggestions);
addrType.value = type;
}

/** @func 取得歷史建議路線 */
async function getHistoryAddr(query: any) {
try {
const params: any = {
longTermCareCardId: query,
take: 5,
};
const { result }: any = await addressHistory(params);
fromFavoriteAddr.value = result.From?.map((str: string, i: number) => {
return { description: str, place_id: str, name: `歷史${i + 1}` };
});
toFavoriteAddr.value = result.To?.map((str: string, i: number) => {
return { description: str, place_id: str, name: `歷史${i + 1}` };
});
} catch (error) {
console.error(error);
}
}
const loading = ref(false);

/** @func 整理目前要顯示的地址陣列 */
function displaySuggestions(predictions: any[], status: any) {
isShowsuggest.value = true;

if (status !== window.google.maps.places.PlacesServiceStatus.OK) {
searchResults.value = addrType.value === 'to' ? toFavoriteAddr.value : fromFavoriteAddr.value;
loading.value = true;
return;
}
if (addrType.value.length === 0) {
searchResults.value = predictions;
loading.value = true;
return;
}
searchResults.value = addrType.value === 'to' ? toFavoriteAddr.value.concat(predictions) : fromFavoriteAddr.value.concat(predictions);
loading.value = true;
}

/** @func 點擊建議選項 */
function clickOption(input: string, type: string) {
if (type === 'from') {
toTargetPlace(input, 'from');
startPoint.value = input;
} else {
endPoint.value = input;
toTargetPlace(input, 'to');
}
isShowsuggest.value = false;
if (endPoint.value !== '' && startPoint.value !== '') {
isDone.value = true;
useDirectionsService(startPoint.value, endPoint.value);
}
}

/** @const Google Map 設定 */
const mapOptions = {
center: {
lat: 25.0374865,
lng: 121.5647688,
},
zoom: 13,
mapId: 'templateID',
};

onMounted(() => {
initMap(mapOptions);
initDirectionsService();
initAutoComplete();
getHistoryAddr(route.params.id);
});
</script>

方法一 : Google Map API 最佳化路徑服務 ( google.maps.DirectionsService、google.maps.DirectionsRenderer )

優點

  1. 可以直接使用 Google 寫好的 API,並有文件可以參照
  2. 不用自己地圖的縮放和中間點,串接好就可以用了

缺點

  1. 需閱讀大量文件找出適合自己的 API
  2. 如果節流沒寫好或是流量大的話收費會很可觀
  3. 需要時常關注更新,修正棄用的 API

撰寫全域共用邏輯

composables/useGoogleMap.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import { Loader } from '@googlemaps/js-api-loader';

/** @interface googleMap設定 */
interface MapOptions {
center: { lat: number; lng: number };
zoom: number;
}

interface Location {
lat: number;
lng: number;
}

const loader = new Loader({
apiKey: import.meta.env.VITE_APP_MAP_KEY,
version: 'weekly',
libraries: ['places'],
language: 'zh-TW',
});

/**
* @func googleMap 相關API
* @desc 參考官方文件 https://developers.google.com/maps/documentation/javascript/load-maps-js-api?hl=zh-tw
*/
export function useGoogleMap() {
const map = ref<google.maps.Map | null>(null);
const autocompeleteService = ref<google.maps.places.AutocompleteService | null>(null);
/** @const 地理編碼服務 */
const geocoder = ref<google.maps.Geocoder | null>(null);
const directionsService = ref<google.maps.DirectionsService | null>(null);
const directionsRenderer = ref<google.maps.DirectionsRenderer | null>(null);
/** @const 圖標陣列 */
const markerList = ref([]);
/** @const 中繼站陣列 */
const waypoints = ref([]);

const addressFrom = ref<Location>({
lat: 25.0374865,
lng: 121.5647688,
});
const addressTo = ref<Location>({
lat: 25.0374865,
lng: 121.5647688,
});

/** @func 創建地圖 */
async function initMap(mapOptions: MapOptions) {
await loader
.load()
.then((google) => {
nextTick(async () => {
map.value = new google.maps.Map(document.getElementById('map') as HTMLElement, mapOptions);
geocoder.value = new google.maps.Geocoder();
});
})
.catch((e) => {
console.log(e);
});
}

/** @func 創建最佳化路徑服務 */
function initDirectionsService(addressFrom: string, addressTo: string) {
loader
.load()
.then((google) => {
nextTick(() => {
clearMarker();
directionsService.value = new google.maps.DirectionsService();
directionsRenderer.value = new google.maps.DirectionsRenderer({ map: map.value });

displayRoute(addressFrom, addressTo, directionsService.value, directionsRenderer.value);
});
})
.catch((e) => {
console.log(e);
});
}

function displayRoute(origin: string, destination: string, service: google.maps.DirectionsService, display: google.maps.DirectionsRenderer) {
service
.route({
origin: origin,
destination: destination,
// waypoints: [{ location: 'Adelaide, SA' }, { location: 'Broken Hill, NSW' }],
waypoints: waypoints.value,
travelMode: google.maps.TravelMode.DRIVING,
avoidTolls: true,
})
.then((result: google.maps.DirectionsResult) => {
display.setDirections(result);
})
.catch((e) => {
alert('Could not display directions due to: ' + e);
});
}

/** @func 創建化自動完成建議地址服務 */
function initAutoComplete() {
loader
.load()
.then((google) => {
nextTick(() => {
autocompeleteService.value = new window.google.maps.places.AutocompleteService();
});
})
.catch((e) => {
console.log(e);
});
}

/** @func 設定圖標 */
function setMarker(lat: number, lng: number) {
loader
.load()
.then((google) => {
nextTick(async () => {
const marker = new google.maps.Marker({
// map: map.value,
position: { lat, lng },
animation: google.maps.Animation.DROP,
});

marker.setMap(map.value);
markerList.value.push(marker);
});
})
.catch((e) => {
console.log(e);
});
}

function clearMarker() {
markerList.value.forEach((marker) => {
marker.setMap(null);
});
markerList.value = [];
}

/** @func 取得地址/地標經緯度 */
function toTargetPlace(address: string, type: string) {
if (!geocoder.value) return;

geocoder.value.geocode({ address }, (results, status) => {
if (status === 'OK') {
if (type === 'from') {
addressFrom.value = {
lat: results![0].geometry.location.lat(),
lng: results![0].geometry.location.lng(),
};
} else {
addressTo.value = {
lat: results![0].geometry.location.lat(),
lng: results![0].geometry.location.lng(),
};
}
setMarker(results![0].geometry.location.lat(), results![0].geometry.location.lng());
if (!map.value) return;

map.value.setCenter({
lat: results![0].geometry.location.lat(),
lng: results![0].geometry.location.lng(),
});
map.value.setZoom(16);
} else {
throw new Error(`Geocode was not successful for the following reason: ${status}`);
}
});
}

return { map, geocoder, autocompeleteService, directionsService, initMap, initDirectionsService, initAutoComplete, setMarker, toTargetPlace };
}

方法二 : 為節流以 API 紀錄編碼路徑,使用 google.maps.Polyline 來繪製

優點

  1. 節省成本

缺點

  1. 需要自己設定地圖的縮放和中間點、刪除前一個路線

撰寫全域共用邏輯

composables/useGoogleMap.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
import { Loader } from '@googlemaps/js-api-loader';
import { getDistanceByAddr } from '@/api/mapaddrpositions';

/** @interface googleMap設定 */
interface MapOptions {
center: { lat: number; lng: number };
zoom: number;
}

interface Location {
lat: number;
lng: number;
}

const loader = new Loader({
apiKey: import.meta.env.VITE_APP_MAP_KEY,
version: 'weekly',
libraries: ['places', 'geometry'],
language: 'zh-TW',
});

/**
* @func googleMap 相關API
* @desc 參考官方文件 https://developers.google.com/maps/documentation/javascript/load-maps-js-api?hl=zh-tw
*/
export function useGoogleMap() {
let _google: any = null;
const map = ref<google.maps.Map | null>(null);
const autocompeleteService = ref<google.maps.places.AutocompleteService | null>(null);
/** @const 地理編碼服務 */
const geocoder = ref<google.maps.Geocoder | null>(null);
/** @const googleMap 繪圖 */
const polyLine = ref<google.maps.Polyline | null>(null);
/** @const API 儲存路線(Google Maps Polyline 編碼的路徑) */
const path = ref<string>('null');
let center = { lat: 0, lng: 0 };

/** @const 圖標陣列 */
const markers: google.maps.Marker[] = [];
/** @const 圖標上車地址 */
let markerStart: google.maps.Marker | null = null;
/** @const 圖標下車地址 */
let markerEnd: google.maps.Marker | null = null;

const addressFrom = ref<Location>({
lat: 25.0374865,
lng: 121.5647688,
});
const addressTo = ref<Location>({
lat: 25.0374865,
lng: 121.5647688,
});

/** @func 創建地圖 */
async function initMap(mapOptions: MapOptions) {
await loader
.load()
.then((google) => {
nextTick(async () => {
_google = google;
map.value = new google.maps.Map(document.getElementById('map') as HTMLElement, mapOptions);
geocoder.value = new google.maps.Geocoder();
});
})
.catch((e) => {
console.log(e);
});
}

async function setPolyLine(startPoint: string, endPoint: string) {
const { result } = (await getDistanceByAddr({ FromAddr: startPoint, ToAddr: endPoint })) as any;
path.value = result.polyLine;
center = { lat: (result.fromLat + result.toLat) / 2, lng: (result.fromLng + result.toLng) / 2 };
drawPolyLine();
}

/**
* @func 繪製編碼的路線
* @desc 參考官方文件 https://developers.google.com/maps/documentation/javascript/examples/geometry-encodings、https://developers.google.com/maps/documentation/javascript/shapes?hl=zh-tw
*/
async function drawPolyLine() {
if (polyLine.value) {
// 清除前一個路線
toRaw(polyLine.value).setMap(null);
}
polyLine.value = new _google.maps.Polyline({
path: _google.maps.geometry.encoding.decodePath(path.value),
strokeColor: '#FF0000',
strokeOpacity: 0.8,
strokeWeight: 5,
map: map.value,
});
map.value!.setCenter({ lat: center.lat, lng: center.lng });
map.value!.setZoom(11);
}

/** @func 創建化自動完成建議地址服務 */
async function initAutoComplete() {
await loader
.load()
.then((google) => {
nextTick(() => {
autocompeleteService.value = new window.google.maps.places.AutocompleteService();
});
})
.catch((e) => {
console.log(e);
});
}

/**
* @func 設定圖標
* @desc 參考官方文件 https://developers.google.com/maps/documentation/javascript/examples/marker-remove
*/
function setMarker(lat: number, lng: number, type: string) {
const marker = new _google.maps.Marker({
map: map.value,
position: { lat, lng },
animation: _google.maps.Animation.DROP,
});
if (type === 'from') {
if (markerStart) {
markerStart.setMap(null);
}
markerStart = marker;
} else if ('to') {
if (markerEnd) {
markerEnd.setMap(null);
}
markerEnd = marker;
} else {
markers.push(marker);
}
}

/** @func 取得地址/地標經緯度 */
function toTargetPlace(address: string, type: string) {
if (!geocoder.value) return;
geocoder.value.geocode({ address }, (results, status) => {
if (status === 'OK') {
if (type === 'from') {
addressFrom.value = {
lat: results![0].geometry.location.lat(),
lng: results![0].geometry.location.lng(),
};
} else {
addressTo.value = {
lat: results![0].geometry.location.lat(),
lng: results![0].geometry.location.lng(),
};
}
setMarker(results![0].geometry.location.lat(), results![0].geometry.location.lng(), type);
if (!map.value) return;

map.value.setCenter({
lat: results![0].geometry.location.lat(),
lng: results![0].geometry.location.lng(),
});
map.value.setZoom(16);
} else {
throw new Error(`Geocode was not successful for the following reason: ${status}`);
}
});
}

return { map, geocoder, autocompeleteService, initMap, initAutoComplete, setMarker, toTargetPlace, setPolyLine };
}

需要注意的小地方

toRaw() - Vue官方文件

由於物件多包了一層 Proxy,需要用 toRaw 來解構,不然在刪除圖標或是路線時會有問題

toRaw 定義

根據一個Vue 建立的代理程式傳回其原始物件。

類型

1
function toRaw<T>(proxy: T): T

詳細資訊

toRaw()可以傳回由 ==reactive()、readonly()、shallowReactive()或shallowReadonly()== 建立的代理對應的==原始物件==。

這是一個可以用於臨時讀取而不引起代理訪問/追蹤開銷,或是寫入而不觸發更改的特殊方法。不建議保存原始物件的持久引用,請謹慎使用。

範例

1
2
3
4
const foo = {}
const reactiveFoo = reactive(foo)

console.log(toRaw(reactiveFoo) === foo) // true

參考資料

GoogleMap官方文件

使用Google Map API(Directions Service)獲取及顯示最佳路徑