用 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

gh

目前畫面上的資料量很多,一般會使用聚合的方式,讓這些圖示聚集起來,
等到要放大的時候才會全部顯示。

聚合的功能要從 DataSource 裡面載入,所以要改寫一下 billboard 的生成方式。
這些 class 生成實例的過程會回傳實例本身,因此要用變數存起來,
這樣後續才能用一些內建函式把這個實例移除。

在 Cesium 裡面,比較大量的資料集是可以用 DataSource 做管理的,
這邊也稍微改寫一下參數,讓函式看起來比較有通用性:

const motelDataSource = new Cesium.CustomDataSource("motelData");

viewer.dataSources.add(motelDataSource);

function addBillboard(data, dataSource) {
  dataSource.entities.add({
  // 略...
}

確定改用 DataSource 生成資料且有正常載入後,就可以啟動聚合事件,
聚合啟動後必須設定它聚合起來會變成什麼(可以是 pointbillboard 等),
以及要顯示什麼文字(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 會告訴你解答(?),我們只需要擷取到這些資訊即可:

gh

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 版都會有這個按鈕,
裡面就是開關圖層的邏輯:

gh

我們也可以實作一個按鈕來達到開關圖層的效果,
圖層有 show 這個屬性控制顯示與否,但是必須先把剛剛 addImageryProvider 的結果存起來,
addImageryProvider 會返回一個 Layer 物件,裡面才有 show

const taiwanMapLayer = viewer.imageryLayers.addImageryProvider(taiwanMap);

btnToggleMap.addEventListener('click', () => {
  const isShow = taiwanMapLayer.show;
  const text = isShow ? '關閉台灣 E-map' : '開啟台灣 E-map';

  taiwanMapLayer.show = !isShow;
  btnToggleMap.textContent = text;
});

到目前為止算是大功告成,已經完成一個簡單的圖台系統囉~

完整程式碼可參考:CodePen 連結


參考資料