常規部落格看膩了,使用openlayers製作旅行地圖的個人部落格?

發表於2023-09-18

由於上半年經常跑出去玩,突然想做一個旅行地圖的部落格,想起之前接觸過 openlayers 的專案,也懶得去調查別的庫了,直接用 openlayers 開幹。由於github經常構建失敗,我部署到vercel上去了,現在能正常訪問了,而且感覺速度也稍微比github快一些。

ol_plane.gif

安裝

vue 的專案搭建就不說了,直接安裝 ol 就可以開寫了

npm i ol

建立地圖

const { center, zoom, minZoom, maxZoom, extent } = MAP_DEFAULT_OPTIONS;
const map = new Map({
  target: "map",
  layers: [],
  controls: [],
});
map.setView(
  new View({
    center: fromLonLat(center),
    zoom,
    minZoom,
    maxZoom,
    constrainResolution: true,
    extent: transformExtent(extent, EPSG4326, map.getView().getProjection()),
  })
);

新增圖層

建立圖層,我這裡用的是 geojson 的資料建立的,可以在網上找到你想要建立地圖的 geojson 資料。

const layer = new Vector({
  source: new SourceVector({
    url,
    format: new GeoJSON(),
  }),
});

layer.setStyle(CreateLayerStyle);

建立多個圖層新增到組內,比如亞洲圖層,中國圖層

const layerGroup = new Group({
  layers: [asiaLayer, chinaLayer],
});
map.addLayer(layerGroup);

實現放大現在省份圖層

由於中國圖層的 geojson 就只包含省份的邊界線,我想要在放大的時候載入出城市的邊界線,就得新增省份的 geojson 資料。

監聽地圖的 change 事件,判斷縮放發生大於某個數的時候,新增對應的省份圖層

  • LayerCacheMap 省份圖層
  • currentExtent 當前檢視範圍
  • isCityInView 判斷省份是否在當前檢視中
  • layer.setVisible 設定圖層顯示隱藏
map.getView().on("change", function (event) {
  const mapView = event.target;
  // 獲取新的縮放級別
  const zoom = event.target.getZoom();
  // 當前檢視範圍
  const currentExtent = mapView.calculateExtent(map.getSize());

  const transformedExtent = transformExtent(
    currentExtent,
    mapView.getProjection(),
    EPSG4326
  );

  if (zoom > index) {
    // 顯示2級塗層
    for (const key in ALL_EXTENT) {
      const extent = ALL_EXTENT[key];

      // 判斷省份是否在當前檢視中
      const isCityInView = intersects(extent, transformedExtent);
      const layer = LayerCacheMap[key];
      if (!layer) continue;

      if (isCityInView) {
        layer.setVisible(true);
      } else {
        layer.setVisible(false);
      }
    }
  } else {
    // 移除2級塗層
    for (const key in ALL_EXTENT) {
      const layer = LayerCacheMap[key];
      if (layer) layer.setVisible(false);
    }
  }
});
  • 效果

ol_zoom.gif

實現主題切換

監聽 isDark 的變化,遍歷所有圖層,使用 layer.setStyle 改變圖層的 style

const isDark = useDark();

watch(isDark, () => {
  for (const key in LayerCacheMap) {
    if (Object.prototype.hasOwnProperty.call(LayerCacheMap, key)) {
      const map = LayerCacheMap[(key as any) as LayerIndex];
      for (const key in map) {
        if (Object.prototype.hasOwnProperty.call(map, key)) {
          const layerMap = map[key];
          if (layerMap.layer) {
            // 設定主題
            layerMap.layer.setStyle(CreateLayerStyle);
          }
        }
      }
    }
  }
});
  • 效果

ol_change_theme.gif

新增標點

  • 建立一個 marker layer 圖層來收集所有的點
  • 透過資料批次建立點要素,設定樣式
const container = new Vector({
  source: new SourceVector(),
});

// 獲取標點的資料
const markerList = CreateMapMarkerData();

markerList.forEach((item) => {
  // 建立點要素,新增到container layer中
  const pointFeature = CreatePointFeature(item);
  if (pointFeature) container.getSource()?.addFeature(pointFeature);
});
  • 根據位置資訊建立點要素
const pointFeature = new Feature({
  geometry: new Point(fromLonLat(item.coords)), // 設定點的座標
  info: item,
});

// 建立一個圖示樣式
const iconStyle = new Style({
  image: new Icon({
    src: "/images/icons/marker.svg",
    color: "red",
    scale: 1,
    anchor: [0.15, 0.9], // 圖示的錨點位置
  }),
});
pointFeature.setStyle(iconStyle);
  • 效果

ol_marker.png

為標點新增事件

  1. 移動到標點出顯示標點資訊
  • 使用建立互動事件
  • layer 互動圖層為 marker container
  • condition 互動條件為滑鼠懸停 pointerMove
import { pointerMove } from "ol/events/condition";

const interaction = new Select({
  layers: [layer], // 指定可以觸發互動的圖層
  condition: pointerMove, // 滑鼠觸發條件
  style: null, // 禁用預設樣式
});
  1. 繫結互動事件觸發的回撥函式
  • 獲取標點 event.selected[0]
  • 獲取標點資訊 selectedFeature.get("info")
  • 在滑鼠移入標點時觸發相應的事件,比如修改指標
  • 滑鼠移出時觸發相應的事件
let markerInfo: MarkInfo = {
  info: {},
  coords: [],
};

interaction.on("select", (event) => {
  // 懸停事件觸發
  if (event.selected.length > 0) {
    const selectedFeature = event.selected[0];
    // 儲存標點資訊
    markerInfo.info = selectedFeature.get("info");

    const geometry = selectedFeature.getGeometry();
    if (geometry instanceof Point) {
      // 儲存標點位置
      markerInfo.coords = geometry.getCoordinates();
    }

    // 設定 preview 的顯示記憶體
    const element = document.getElementById("map_marker_preview");

    element.textContent = markerInfo.info.title;
    // ...

    // 設定滑鼠指標為 pointer
    map.getTargetElement().style.cursor = "pointer";
  } else {
    // 滑鼠移出觸發
    // ...

    map.getTargetElement().style.cursor = "default";
  }
});
  1. 新增點選事件

觸發點選事件跳轉到對應的連結

import { click } from "ol/events/condition";

const interaction = new Select({
  layers: [layer],
  condition: click,
  style: null,
});

interaction.on("select", (event) => {
  if (event.selected.length > 0) {
    const selectedFeature = event.selected[0];
    const info = selectedFeature.get("info");
    if (info?.route) router.push(info?.route);
  }
});
  • 效果

ol_marker_preview.gif

航行路線

其實標點做完已經完成了我的目標和想要的效果了,不過最近比較清閒,就想加點花哨的東西,新增一個飛機飛過的航行絡線。

  1. 建立飛機、路線圖層
const source = new SourceVector();
const layer = new Vector({ source });
map.addLayer(layer);
  1. 建立一架飛機
  • extent 目的地的座標
  • degrees 飛機初始的旋轉角度
  • countDegrees 透過起始座標和終點座標來計算 degrees
const extent = transform(event?.coords, EPSG3857, EPSG4326);
const degrees = countDegrees(START_POINT, extent);

const feature = new Feature({ geometry: new Point([]) });
const style = new Style({
  image: new Icon({
    src: "/images/icons/plane.svg",
    scale: 1,
    rotation: toRadians(45 + 360 - degrees),
  }),
});
feature.setStyle(style);
source.addFeature(feature);
  1. 飛行路線
  • 根據標點建立不同的路線
  • 使用 LineString 建立線段要素
  • interpolatePoints 根據起始點和終點插值(我的效果是使用貝塞爾曲線建立的)
const features: Record<string, Feature> = {};
const markers = CreateMapMarkerData();

markers
  .filter((m) => !!m.coords)
  .forEach((marker) => {
    // 插值
    const coords = interpolatePoints(START_POINT, marker.coords, 100);

    const feature = new Feature({
      geometry: new LineString(coords),
    });
    // 設定樣式
    feature.setStyle(CreateLineStyle());
    features[marker.route] = feature;
  });
  1. 飛行動畫

根據線路的座標在設定的時間內 Duration 不停的改變飛機的座標位置

const line = lineFeature?.getGeometry();
const coordsList = line.getCoordinates();
let startTime = new Date().getTime();

function animate() {
  const currentTime = new Date().getTime();
  const elapsedTime = currentTime - startTime;
  const fraction = elapsedTime / Duration;
  const index = Math.round(coordsList.length * fraction);

  if (index < coordsList.length) {
    const geometry = feature.getGeometry();
    if (geometry instanceof Point) {
      geometry?.setCoordinates(coordsList[index]);
    }
    // TODO 飛機轉向

    requestAnimationFrame(animate);
  } else {
    callback();
  }
}

animate();
  • 效果

ol_plane.gif

左上角是資訊預覽和路線預覽的開關。

可以看到飛機的初始方向是對的,但飛行起來就不對了,因為我還沒有做哈哈哈哈,需要在動畫裡每一幀根據座標去計算飛機的角度,之後再更新吧。

飛機轉向(更新)

其實也很簡單,就是記錄一下上一次的位置資訊,計算一下偏移角度呼叫 setRotation 在動畫的每一幀設定一下就可以了

if (lastCoords) {
  const degrees = toRadians(
    45 + 360 - countDegrees(lastCoords, coordsList[index])
  );
  (feature.getStyle() as Style)?.getImage()?.setRotation(degrees);
}
lastCoords = coordsList[index];
  • 效果

ol_plane_rotation.gif

總結

如果感興趣的話可以關注我的github

對我的部落格專案感興趣可以關注my blog github,我會不定期地持續地更新,歡迎大佬新增友鏈。

這裡是旅行地圖預覽地址,由於github actions經常會因為錯誤提交而構建失敗,之前vercel構建出來頁面重新整理會404。

現在已經解決了vercel重新整理404的問題,於是重新部署到vercel上m,my blog

所有的展示圖片來自錄屏再透過my tools轉換為 gif。

相關文章