用 Cesium 製作旅館地圖
GIS 是個很冷門的題材,但要建構一個完整的 GIS 系統,
其背後的演算法很複雜, 如果不是公司專案因素,我會碰到 GIS 的機會,
大概只有某天突然想起「口罩地圖」然後很想看那是怎麼實作的時候吧 XD
環境建立
Cesium 是我過去在公司的專案上用到的一套 GIS 工具,
有人的地方就有江湖,有 JS 的地方就有 React,所以...它也有 React 的封裝版,
叫做 Resium,但這邊先示範原生的 CDN 為主:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Include the CesiumJS JavaScript and CSS files -->
<script src="https://cesium.com/downloads/cesiumjs/releases/1.119/Build/Cesium/Cesium.js"></script>
<link
href="https://cesium.com/downloads/cesiumjs/releases/1.119/Build/Cesium/Widgets/widgets.css"
rel="stylesheet"
/>
</head>
<body>
<div id="cesiumContainer"></div>
</body>
<script type="module">
const viewer = new Cesium.Viewer("cesiumContainer");
</script>
</html>
GIS 的要素
除了建構工具的選擇外,GIS 不外乎就是下面的要素:
- 座標
- 事件
- 圖層
也就是說我們只要有這些資訊,就能做到簡單的互動地圖了!
座標
要在地圖上準確定義出某個東西的座標,要用什麼當作參考呢?
就是經緯度!因此在串接資料時,只要資料來源裡面是有經緯度的,
那就一定能在地圖上標示出來。
以 高雄城市資料平台 高雄市一般旅館資料為例,將 JSON 檔下載下來後,
甚至能直接看到中文命名的屬性:
{
// 略...
"經度Lng": "120.2956306",
"緯度Lat": "22.6270351964"
}
接下來可以透過這些座標資料在地圖上生成圖示。
目前起始畫面是從外太空看向整個地球的,所以我希望改變起始位置,
一樣需要用到高雄的經緯度 (120.3119, 22.6208)
,控制畫面移動的東西是 camera:
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(120.3119, 22.6208, 10000),
});
高雄城市資料平台有給 JSON 格式的資料,所以直接 fetch 它即可:
fetch(
"https://api.kcg.gov.tw/api/service/Get/8ed53368-e292-4e2a-80a7-434cf497220c"
).then((response) => {
response.json().then((res) => {
res.data.forEach((item) => {
addBillboard(item);
});
});
});
// 產生圖示的函式
function addBillboard(data) {
console.log(data);
}
確認資料能夠接上之後,就可以來實作 addBillboard
這個函式,
Cesium 可以生成不同類型的實例並顯示在畫面上,這邊要示範的是 Billboard
:
const pinBuilder = new Cesium.PinBuilder();
function addBillboard(data) {
viewer.entities.add({
name: data["旅宿名稱"],
id: data.seq,
position: Cesium.Cartesian3.fromDegrees(data["經度Lng"], data["緯度Lat"]),
billboard: {
image: pinBuilder.fromText("摩鐵", Cesium.Color.PINK, 100),
width: 64,
height: 64,
},
});
}
官方的文件不是很好閱讀,不過直接看它們的 demo 會發現,
程式碼架構其實很簡單,所以依樣畫葫蘆抄下來就好。
billboard
本身可以自定義圖案,這邊我是用 Cesium 內建的 pinBuilder
。
Cesium 預設點擊到實例是可以查看資訊的,直接點選剛剛生成的 billboard
,
會彈出剛剛賦予它的 name
:
目前畫面上的資料量很多,一般會使用聚合的方式,讓這些圖示聚集起來,
等到要放大的時候才會全部顯示。
聚合的功能要從 DataSource
裡面載入,所以要改寫一下 billboard
的生成方式。
這些 class 生成實例的過程會回傳實例本身,因此要用變數存起來,
這樣後續才能用一些內建函式把這個實例移除。
在 Cesium 裡面,比較大量的資料集是可以用 DataSource
做管理的,
這邊也稍微改寫一下參數,讓函式看起來比較有通用性:
const motelDataSource = new Cesium.CustomDataSource("motelData");
viewer.dataSources.add(motelDataSource);
function addBillboard(data, dataSource) {
dataSource.entities.add({
// 略...
}
確定改用 DataSource
生成資料且有正常載入後,就可以啟動聚合事件,
聚合啟動後必須設定它聚合起來會變成什麼(可以是 point
、billboard
等),
以及要顯示什麼文字(label
),不然畫面上圖示都會因為聚合事件消失。
這邊我希望它聚合起來一樣是 billboard
,
而內建的 pinBuilder
可以內嵌指定文字,所以就不另外設定 label
了:
function initDataSource(dataSource) {
dataSource.clustering.enabled = true;
dataSource.clustering.pixelRange = 50; // 大概要聚合幾 pixel 內的物件
dataSource.clustering.minimumClusterSize = 2; // 最小聚合數量
dataSource.clustering.clusterEvent.addEventListener(
(clusteredEntities, cluster) => {
cluster.label.show = false; // label 預設會顯示 這邊我關掉
cluster.billboard.show = true; // billboard 預設不顯示 要開起來
cluster.billboard.width = 100;
cluster.billboard.height = 100;
cluster.billboard.image = pinBuilder.fromText(
`${clusteredEntities.length} 間`,
Cesium.Color.BLACK,
100
);
}
);
}
事件
Cesium 是以 canvas 的方式掛在畫面上的,所以如果它沒有內建一些互動事件的話,
那就頭大啦......當然 Cesium 是有的,別擔心,就是 ScreenSpaceEventHandler:
// 設定事件
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement) => {
console.log(movement);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
啟用 setInputAction
並指定事件類型為 LEFT_CLICK
,
現在在畫面上隨便點擊都能看到 console 帶出的資料:
{position: wt}
position: wt
x: 753.8210172653198
y: 348.2755460739136
[[Prototype]]: Object
[[Prototype]]: Object
Cesium 可以用 Scene
下面的 pick
方法,去辨識點擊的位置,
如果 pick
到的東西是 Cesium 裡面的一個物件,那麼可以用 defined
方法指向它的實例:
handler.setInputAction((movement) => {
const pickedObject = viewer.scene.pick(movement.position);
if (Cesium.defined(pickedObject.id)) {
console.log(pickedObject.id);
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
現在已經能看到點擊的 billboard
資料了,
如果是與後端協作的話,通常會在點擊到 billboard
時取出我們先前賦予它的 id
,
再跟後端拿更詳細的資料,這邊我們直接把 JSON 的資料直接定義到 billboard
裡面就可以了,
所以要稍微改寫一下 addBillboard
:
function addBillboard(data, dataSource) {
dataSource.entities.add({
name: data["旅宿名稱"],
id: data.seq,
address: data["地址"],
phone: data["電話"],
website: data["網址"],
email: data["電子郵件"],
position: Cesium.Cartesian3.fromDegrees(data["經度Lng"], data["緯度Lat"]),
billboard: {
image: pinBuilder.fromText("摩鐵", Cesium.Color.PINK, 100),
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
width: 64,
height: 64,
},
});
}
// 旅館的資料大概有這些
// {
// "seq": 390,
// "序號": "390",
// "類別": "旅館",
// "星等": "",
// "旅宿名稱": "麗馨麗登精品商旅",
// "縣市": "高雄市",
// "鄉鎮": "鳳山區",
// "地址": "830高雄市鳳山區曹公路77號",
// "電話": "07-7462128",
// "傳真": "07-7462129",
// "房間數": "20",
// "電子郵件": "leesing.hotel@gmail.com",
// "網址": "http://www.leesing-hotel.com",
// "郵遞區號": "830",
// "經度Lng": "120.357025599",
// "緯度Lat": "22.6295505022"
// }
Entity
給我們很大的彈性可以自訂屬性,但這麼做也是比較危險的,
有可能會複寫到原型鍊的東西,所以一般只會存 id,然後再拿這個 id 去索引資料。
現在稍微調整一下版面,就可以把資料塞到畫面上啦:
handler.setInputAction((movement) => {
const pickedObject = viewer.scene.pick(movement.position);
if (Cesium.defined(pickedObject.id)) {
const infoBox = document.querySelector(".infoBox");
const { name, address, phone, website, email } = pickedObject.id;
infoBox.innerHTML = /* HTML */ `
<div class="infoBox-content">
<h2>${name}</h2>
<p>地址:${address}</p>
<p>電話:${phone}</p>
${website ? `<p>網站:<a href="${website}">${website}</a></p>` : ""} ${email
? `<p>電子信箱:<a href="mailto:${email}">${email}</a></p>`
: ""}
</div>
`;
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
Cesium 預設點擊物件會彈出 infoBox 並有一個綠色鎖定框,
這個事件是可以關掉的,通常初始化時會帶入 options 去關掉:
const viewer = new Cesium.Viewer("cesiumContainer", {
infoBox: false,
selectionIndicator: false,
});
這樣高雄瑟瑟網已經完成得差不多啦!
圖層
GIS 系統裡面很多東西可以透過圖層的方式疊加或混合渲染,
包含模型、地形、等高線等等,類似 Photoshop 的圖層功能,
是可以開開關關的,包含前面我們生成的 Billboard
。
Cesium 透過 ImageryProvider
管理 畫面的底圖,
網路上有很多地圖服務 API 是有提供底圖可以串接的,但大多要先申請 API Key,
這裡我們使用免費的「台灣通用電子地圖」即可(讓大家知道政府有在做事)。
GIS 有國際規範,所以看不懂下圖這些密密麻麻的代號沒關係,
GPT 會告訴你解答(?),我們只需要擷取到這些資訊即可:
ImageryProvider
有好幾種,第三方網路服務的底圖要使用 WebMapServiceImageryProvider:
const taiwanMap = new Cesium.WebMapTileServiceImageryProvider({
url: "https://wmts.nlsc.gov.tw/wmts",
style: "default",
format: "image/jpeg",
tileMatrixSetID: "EPSG:3857",
maximumLevel: 19,
layer: "EMAP",
});
viewer.imageryLayers.addImageryProvider(taiwanMap);
可以發現剛剛看到的規格,Cesium 都有指定要填寫,
因此我們只要填格子就好,Cesium 會自動去解析 url 裡面的 xml 資料。
如果有留意過 Google Map 的介面,應該會發現網頁版或 App 版都會有這個按鈕,
裡面就是開關圖層的邏輯:
我們也可以實作一個按鈕來達到開關圖層的效果,
圖層有 show
這個屬性控制顯示與否,但是必須先把剛剛 addImageryProvider
的結果存起來,
addImageryProvider
會返回一個 Layer
物件,裡面才有 show
:
const taiwanMapLayer = viewer.imageryLayers.addImageryProvider(taiwanMap);
btnToggleMap.addEventListener("click", () => {
const isShow = taiwanMapLayer.show;
const text = isShow ? "