主頁 > 企業開發 > 從零打造一個Web地圖引擎

從零打造一個Web地圖引擎

2022-01-19 17:14:11 企業開發

說到地圖,大家一定很熟悉,平時應該都使用過百度地圖、高德地圖、騰訊地圖等,如果涉及到地圖相關的開發需求,也有很多選擇,比如前面的幾個地圖都會提供一套js API,此外也有一些開源地圖框架可以使用,比如OpenLayersLeaflet等,

那么大家有沒有想過這些地圖是怎么渲染出來的呢,為什么根據一個經緯度就能顯示對應的地圖呢,不知道沒關系,本文會帶各位從零實作一個簡單的地圖引擎,來幫助大家了解GIS基礎知識及Web地圖的實作原理,

選個經緯度

首先我們去高德地圖上選個經緯度,作為我們后期的地圖中心點,打開高德坐標拾取工具,隨便選擇一個點:

image-20220104161043710.png

筆者選擇了杭州的雷峰塔,經緯度為:[120.148732,30.231006]

瓦片url分析

地圖瓦片我們使用高德的在線瓦片,地址如下:

https://webrd0{1-4}.is.autonavi.com/appmaptile?x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=1&style=8

目前各大地圖廠商的瓦片服務遵循的規則是有不同的:

谷歌XYZ規范:谷歌地圖、OpenStreetMap、高德地圖、geoq、天地圖,坐標原點在左上角

TMS規范:騰訊地圖,坐標原點在左下角

WMTS規范:原點在左上角,瓦片不是正方形,而是矩形,這個應該是官方標準

百度地圖比較特立獨行,投影、解析度、坐標系都跟其他廠商不一樣,原點在經緯度都為0的位置,也就是中間,向右為X正方向,向上為Y正方向

谷歌和TMS的瓦片區別可以通過該地址可視化的查看:地圖瓦片,

雖然規范不同,但原理基本是一致的,都是把地球投影成一個巨大的正方形世界平面圖,然后按照四叉樹進行分層切割,比如第一層,只有一張瓦片,顯示整個世界的資訊,所以基本只能看到洲和海的名稱和邊界線,第二層,切割成四張瓦片,顯示資訊稍微多了一點,以此類推,就像一個金字塔一樣,底層解析度最高,顯示的細節最多,瓦片數也最多,頂層解析度最低,顯示的資訊很少,瓦片數量相對也最少:

image-20220105134723330.png

每一層的瓦片數量計算公式:

Math.pow(Math.pow(2, n), 2)// 行*列:2^n * 2^n

十八層就需要68719476736張瓦片,所以一套地圖瓦片整體數量是非常龐大的,

瓦片切好以后,通過行列號和縮放層級來保存,所以可以看到瓦片地址中有三個變數:xyz

x:行號
y:列號
z:解析度,一般為0-18

通過這三個變數就可以定位到一張瓦片,比如下面這個地址,行號為109280,列號為53979,縮放層級為17

https://webrd01.is.autonavi.com/appmaptile?x=109280&y=53979&z=17&lang=zh_cn&size=1&scale=1&style=8

對應的瓦片為:

img

關于瓦片的更多資訊可以閱讀瓦片地圖原理,

坐標系簡介

高德地圖使用的是GCJ-02坐標系,也稱火星坐標系,由中國國家測繪局在02年發布,是在GPS坐標(WGS-84坐標系)基礎上經加密后而來,也就是增加了非線性的偏移,讓你摸不準真實位置,為了國家安全,國內地圖服務商都需要使用GCJ-02坐標系

WGS-84坐標系是國際通用的標準,EPSG編號為EPSG:4326,通常GPS設備獲取到的原始經緯度和國外的地圖廠商使用的都是WGS-84坐標系,

這兩種坐標系都是地理坐標系,球面坐標,單位為,這種坐標方便在地球上定位,但是不方便展示和進行面積距離計算,我們印象中的地圖都是平面的,所以就有了另外一種平面坐標系,平面坐標系是通過投影的方式從地理坐標系中轉換過來,所以也稱為投影坐標系,通常單位為,投影坐標系根據投影方式的不同存在多種,在Web開發的場景里通常使用的是Web墨卡托投影,編號為EPSG:3857,它基于墨卡托投影,把WGS-84坐標系投影成正方形:

image-20220104191524330.png

這是通過舍棄了南北85.051129緯度以上的地區實作的,因為它是正方形,所以一個大的正方形可以很方便的被分割為更小的正方形,

坐標系更詳細的資訊可參考GIS之坐標系統,EPSG:3857的詳細資訊可參考EPSG:3857,

經緯度定位行列號

上一節里我們簡單介紹了一下坐標系,按照Web地圖的標準,我們的地圖引擎也選擇支持EPSG:3857投影,但是我們通過高德工具獲取到的是火星坐標系的經緯度坐標,所以第一步要把經緯度坐標轉換為Web墨卡托投影坐標,這里為了簡單,先直接把火星坐標當做WGS-84坐標,后面再來看這個問題,

轉換方法網上一搜就有:

// 角度轉弧度
const angleToRad = (angle) => {
    return angle * (Math.PI / 180)
}

// 弧度轉角度
const radToAngle = (rad) => {
    return rad * (180 / Math.PI)
}

// 地球半徑
const EARTH_RAD = 6378137

// 4326轉3857
const lngLat2Mercator = (lng, lat) => {
    // 經度先轉弧度,然后因為 弧度 = 弧長 / 半徑 ,得到弧長為 弧長 = 弧度 * 半徑 
    let x = angleToRad(lng) * EARTH_RAD; 
    // 緯度先轉弧度
    let rad = angleToRad(lat)
    // 下面我就看不懂了,各位隨意,,,
    let sin = Math.sin(rad)
    let y = EARTH_RAD / 2 * Math.log((1 + sin) / (1 - sin))
    return [x, y]
}

// 3857轉4326
const mercatorTolnglat = (x, y) => {
    let lng = radToAngle(x) / EARTH_RAD
    let lat = radToAngle((2 * Math.atan(Math.exp(y / EARTH_RAD)) - (Math.PI / 2)))
    return [lng, lat]
}

3857坐標有了,它的單位是,那么怎么轉換成瓦片的行列號呢,這就涉及到解析度的概念了,即地圖上一像素代表實際多宣告,解析度如果能從地圖廠商的檔案里獲取是最好的,如果找不到,也可以簡單計算一下(如果使用計算出來的也不行,那就只能求助搜索引擎了),我們知道地球半徑是6378137米,3857坐標系把地球當做正圓球體來處理,所以可以算出地球周長,投影是貼著地球赤道的:

image.png

所以投影成正方形的世界平面圖后的邊長代表的就是地球的周長,前面我們也知道了每一層級的瓦片數量的計算方式,而一張瓦片的大小一般是256*256像素,所以用地球周長除以展開后的世界平面圖的邊長就知道了地圖上每像素代表實際多宣告:

// 地球周長
const EARTH_PERIMETER = 2 * Math.PI * EARTH_RAD
// 瓦片像素
const TILE_SIZE = 256

// 獲取某一層級下的解析度
const getResolution = (n) => {
    const tileNums = Math.pow(2, n)
    const tileTotalPx = tileNums * TILE_SIZE
    return EARTH_PERIMETER / tileTotalPx
}

地球周長算出來是40075016.68557849,可以看到OpenLayers就是這么計算的:

image-20220105143333164.png

3857坐標的單位是,那么把坐標除以解析度就可以得到對應的像素坐標,再除以256,就可以得到瓦片的行列號:

image-20220105185741054.png

函式如下:

// 根據3857坐標及縮放層級計算瓦片行列號
const getTileRowAndCol = (x, y, z) => {
    let resolution = getResolution(z)
    let row = Math.floor(x / resolution / TILE_SIZE)
    let col = Math.floor(y / resolution / TILE_SIZE)
    return [row, col]
}

接下來我們把層級固定為17,那么解析度resolution就是1.194328566955879,雷峰塔的經緯度轉成3857的坐標為:[13374895.665697495, 3533278.205310311],使用上面的函式計算出來行列號為:[43744, 11556],我們把這幾個資料代入瓦片的地址里進行訪問:

https://webrd01.is.autonavi.com/appmaptile?x=43744&y=11556&z=17&lang=zh_cn&size=1&scale=1&style=8

image-20220105150159713.png

一片空白,這是為啥呢,其實是因為原點不一樣,43263857坐標系的原點在赤道和本初子午線相交點,非洲邊上的海里,而瓦片的原點在左上角:

image-20220105202326826.png

再來看下圖會更容易理解:

image-20220106095034453.png

3857坐標系的原點相當于在世界平面圖的中間,向右為x軸正方向,向上為y軸正方向,而瓦片地圖的原點在左上角,所以我們需要根據圖上【綠色虛線】的距離計算出【橙色實線】的距離,這也很簡單,水平坐標就是水平綠色虛線的長度加上世界平面圖的一半,垂直坐標就是世界平面圖的一半減去垂直綠色虛線的長度,世界平面圖的一半也就是地球周長的一半,修改getTileRowAndCol函式:

const getTileRowAndCol = (x, y, z) => {
  x += EARTH_PERIMETER / 2     // ++
  y = EARTH_PERIMETER / 2 - y  // ++
  let resolution = getResolution(z)
  let row = Math.floor(x / resolution / TILE_SIZE)
  let col = Math.floor(y / resolution / TILE_SIZE)
  return [row, col]
}

這次計算出來的瓦片行列號為[109280, 53979],代入瓦片地址:

https://webrd01.is.autonavi.com/appmaptile?x=109280&y=53979&z=17&lang=zh_cn&size=1&scale=1&style=8

結果如下:

image-20220106095801592.png

可以看到雷峰塔出來了,

瓦片顯示位置計算

我們現在能根據一個經緯度找到對應的瓦片,但是這還不夠,我們的目標是要能在瀏覽器上顯示出來,這就需要解決兩個問題,一個是加載多少塊瓦片,二是計算每一塊瓦片的顯示位置,

渲染瓦片我們使用canvas畫布,模板如下:

<template>
  <div  ref="map">
    <canvas ref="canvas"></canvas>
  </div>
</template>

地圖畫布容器map的大小我們很容易獲取:

// 容器大小
let { width, height } = this.$refs.map.getBoundingClientRect()
this.width = width
this.height = height
// 設定畫布大小
let canvas = this.$refs.canvas
canvas.width = width
canvas.height = height
// 獲取繪圖背景關系
this.ctx = canvas.getContext('2d')

地圖中心點我們設在畫布中間,另外中心點的經緯度center和縮放層級zoom因為都是我們自己設定的,所以也是已知的,那么我們可以計算出中心坐標對應的瓦片:

// 中心點對應的瓦片
let centerTile = getTileRowAndCol(
    ...lngLat2Mercator(...this.center),// 4326轉3857
    this.zoom// 縮放層級
)

縮放層級還是設為17,中心點還是使用雷峰塔的經緯度,那么對應的瓦片行列號前面我們已經計算過了,為[109280, 53979]

中心坐標對應的瓦片行列號知道了,那么該瓦片左上角在世界平面圖中的像素位置我們也就知道了:

// 中心瓦片左上角對應的像素坐標
let centerTilePos = [centerTile[0] * TILE_SIZE, centerTile[1] * TILE_SIZE]

計算出來為[27975680, 13818624],這個坐標怎么轉換到螢屏上呢,請看下圖:

image-20220106143543672.png

中心經緯度的瓦片我們計算出來了,瓦片左上角的像素坐標也知道了,然后我們再計算出中心經緯度本身對應的像素坐標,那么和瓦片左上角的差值就可以計算出來,最后我們把畫布的原點移動到畫布中間(畫布默認原點為左上角,x軸正方向向右,y軸正方向向下),也就是把中心經緯度作為坐標原點,那么中心瓦片的顯示位置就是這個差值,

補充一下將經緯度轉換成像素的方法:

// 計算4326經緯度對應的像素坐標
const getPxFromLngLat = (lng, lat, z) => {
  let [_x, _y] = lngLat2Mercator(lng, lat)// 4326轉3857
  // 轉成世界平面圖的坐標
  _x += EARTH_PERIMETER / 2
  _y = EARTH_PERIMETER / 2 - _y
  let resolution = resolutions[z]// 該層級的解析度
  // 米/解析度得到像素
  let x = Math.floor(_x / resolution)
  let y = Math.floor(_y / resolution)
  return [x, y]
}

計算中心經緯度對應的像素坐標:

// 中心點對應的像素坐標
let centerPos = getPxFromLngLat(...this.center, this.zoom)

計算差值:

// 中心像素坐標距中心瓦片左上角的差值
let offset = [
    centerPos[0] - centerTilePos[0],
    centerPos[1] - centerTilePos[1]
]

最后通過canvas來把中心瓦片渲染出來:

// 移影片布原點到畫布中間
this.ctx.translate(this.width / 2, this.height / 2)
// 加載瓦片圖片
let img = new Image()
// 拼接瓦片地址
img.src = https://www.cnblogs.com/wanglinmantan/archive/2022/01/18/getTileUrl(...centerTile, this.zoom)
img.onload = () => {
    // 渲染到canvas
    this.ctx.drawImage(img, -offset[0], -offset[1])
}

這里先來看看getTileUrl方法的實作:

// 拼接瓦片地址
const getTileUrl = (x, y, z) => {
  let domainIndexList = [1, 2, 3, 4]
  let domainIndex =
    domainIndexList[Math.floor(Math.random() * domainIndexList.length)]
  return `https://webrd0${domainIndex}.is.autonavi.com/appmaptile?x=${x}&y=${y}&z=${z}&lang=zh_cn&size=1&scale=1&style=8`
}

這里隨機了四個子域:webrd01webrd02webrd03webrd04,這是因為瀏覽器對于同一域名同時請求的資源是有數量限制的,而當地圖層級變大后需要加載的瓦片數量會比較多,那么均勻分散到各個子域下去請求可以更快的渲染出所有瓦片,減少排隊等待時間,基本所有地圖廠商的瓦片服務地址都支持多個子域,

為了方便看到中心點的位置,我們再額外渲染兩條中心輔助線,效果如下:

image-20220106150430636.png

可以看到中心點確實是雷峰塔,當然這只是渲染了中心瓦片,我們要的是瓦片鋪滿整個畫布,對于其他瓦片我們都可以根據中心瓦片計算出來,比如中心瓦片左邊的一塊,它的計算如下:

// 瓦片行列號,行號減1,列號不變
let leftTile = [centerTile[0] - 1, centerTile[1]]
// 瓦片顯示坐標,x軸減去一個瓦片的大小,y軸不變
let leftTilePos = [
    offset[0] - TILE_SIZE * 1,
    offset[1]
]

所以我們只要計算出中心瓦片四個方向各需要幾塊瓦片,然后用一個雙重回圈即可計算出畫布需要的所有瓦片,計算需要的瓦片數量很簡單,請看下圖:

image.png

畫布寬高的一半減去中心瓦片占據的空間即可得到該方向剩余的空間,然后除以瓦片的尺寸就知道需要幾塊瓦片了:

// 計算瓦片數量
let rowMinNum = Math.ceil((this.width / 2 - offset[0]) / TILE_SIZE)// 左
let colMinNum = Math.ceil((this.height / 2 - offset[1]) / TILE_SIZE)// 上
let rowMaxNum = Math.ceil((this.width / 2 - (TILE_SIZE - offset[0])) / TILE_SIZE)// 右
let colMaxNum = Math.ceil((this.height / 2 - (TILE_SIZE - offset[1])) / TILE_SIZE)// 下

我們把中心瓦片作為原點,坐標為[0, 0],來個雙重回圈掃描一遍即可渲染出所有瓦片:

// 從上到下,從左到右,加載瓦片
for (let i = -rowMinNum; i <= rowMaxNum; i++) {
    for (let j = -colMinNum; j <= colMaxNum; j++) {
        // 加載瓦片圖片
        let img = new Image()
        img.src = https://www.cnblogs.com/wanglinmantan/archive/2022/01/18/getTileUrl(
            centerTile[0] + i,// 行號
            centerTile[1] + j,// 列號
            this.zoom
        )
        img.onload = () => {
            // 渲染到canvas
            this.ctx.drawImage(
                img, 
                i * TILE_SIZE - offset[0], 
                j * TILE_SIZE - offset[1]
            )
        }
    }
}

效果如下:

image-20220106183134954.png

很完美,

拖動

拖動可以這么考慮,前面已經實作了渲染指定經緯度的瓦片,當我們按住進行拖動時,可以知道滑鼠滑動的距離,然后把該距離,也就是像素轉換成經緯度的數值,最后我們再更新當前中心點的經緯度,并清慷訓布,呼叫之前的方法重新渲染,不停重繪造成是在移動的視覺假象,

監聽滑鼠相關事件:

<canvas ref="canvas" @mousedown="onMousedown"></canvas>
export default {
    data(){
        return {
            isMousedown: false
        }
    },
    mounted() {
        window.addEventListener("mousemove", this.onMousemove);
        window.addEventListener("mouseup", this.onMouseup);
    },
    methods: {
        // 滑鼠按下
        onm ousedown(e) {
            if (e.which === 1) {
                this.isMousedown = true;
            }
        },

        // 滑鼠移動
        onm ousemove(e) {
            if (!this.isMousedown) {
                return;
            }
            // ...
        },

        // 滑鼠松開
        onm ouseup() {
            this.isMousedown = false;
        }
    }
}

onMousemove方法里計算拖動后的中心經緯度及重新渲染畫布:

// 計算本次拖動的距離對應的經緯度資料
let mx = e.movementX * resolutions[this.zoom];
let my = e.movementY * resolutions[this.zoom];
// 把當前中心點經緯度轉成3857坐標
let [x, y] = lngLat2Mercator(...this.center);
// 更新拖動后的中心點經緯度
center = mercatorToLngLat(x - mx, my + y);

movementXmovementY屬性能獲取本次和上一次滑鼠事件中的移動值,兼容性不是很好,不過自己計算該值也很簡單,詳細請移步MDN,乘以當前解析度把像素換算成,然后把當前中心點經緯度也轉成3857坐標,偏移本次移動的距離,最后再轉回4326的經緯度坐標作為更新后的中心點即可,

為什么x是減,y是加呢,很簡單,我們滑鼠向右和向下移動時距離是正的,相應的地圖會向右或向下移動,4326坐標系向右和向上為正方向,那么地圖向右移動時,中心點顯然是相對來說是向左移了,因為向右為正方向,所以中心點經度方向就是減少了,所以是減去移動的距離,而地圖向下移動,中心點相對來說是向上移了,因為向上為正方向,所以中心點緯度方向就是增加了,所以加上移動的距離,

更新完中心經緯度,然后清慷訓布重新繪制:

// 清慷訓布
this.clear();
// 重新繪制,renderTiles方法就是上一節的代碼邏輯封裝
this.renderTiles();

效果如下:

whbm.gif

可以看到已經凌亂了,這是為啥呢,其實是因為圖片加載是一個異步的程序,我們滑鼠移動程序中,會不斷的計算出要加載的瓦片進行加載,但是可能上一批瓦片還沒加載完成,滑鼠已經移動到新的位置了,又計算出一批新的瓦片進行加載,此時上一批瓦片可能加載完成并渲染出來了,但是這些瓦片有些可能已經被移除畫布,不需要顯示,有些可能還在畫布內,但是使用的還是之前的位置,渲染出來也是不對的,同時新的一批瓦片可能也加載完成并渲染出來,自然導致了最終顯示的錯亂,

知道原因就簡單了,首先我們加個快取物件,因為在拖動程序中,很多瓦片只是位置變了,不需要重新加載,同一個瓦片加載一次,后續只更新它的位置即可;另外再設定一個物件來記錄當前畫布上應該顯示的瓦片,防止不應該出現的瓦片渲染出來:

{
    // 快取瓦片
    tileCache: {},
    // 記錄當前畫布上需要的瓦片
    currentTileCache: {}
}

因為需要記錄瓦片的位置、加載狀態等資訊,我們創建一個瓦片類:

// 瓦片類
class Tile {
  constructor(opt = {}) {
    // 畫布背景關系
    this.ctx = ctx
    // 瓦片行列號
    this.row = row
    this.col = col
    // 瓦片層級
    this.zoom = zoom
    // 顯示位置
    this.x = x
    this.y = y
    // 一個函式,判斷某塊瓦片是否應該渲染
    this.shouldRender = shouldRender
    // 瓦片url
    this.url = ''
    // 快取key
    this.cacheKey = this.row + '_' + this.col + '_' + this.zoom
    // 圖片
    this.img = null
    // 圖片是否加載完成
    this.loaded = false

    this.createUrl()
    this.load()
  }
    
  // 生成url
  createUrl() {
    this.url = getTileUrl(this.row, this.col, this.zoom)
  }

  // 加載圖片
  load() {
    this.img = new Image()
    this.img.src = https://www.cnblogs.com/wanglinmantan/archive/2022/01/18/this.url
    this.img.onload = () => {
      this.loaded = true
      this.render()
    }
  }

  // 將圖片渲染到canvas上
  render() {
    if (!this.loaded || !this.shouldRender(this.cacheKey)) {
      return
    }
    this.ctx.drawImage(this.img, this.x, this.y)
  }
    
  // 更新位置
  updatePos(x, y) {
    this.x = x
    this.y = y
    return this
  }
}

然后修改之前的雙重回圈渲染瓦片的邏輯:

this.currentTileCache = {}// 清空快取物件
for (let i = -rowMinNum; i <= rowMaxNum; i++) {
    for (let j = -colMinNum; j <= colMaxNum; j++) {
        // 當前瓦片的行列號
        let row = centerTile[0] + i
        let col = centerTile[1] + j
        // 當前瓦片的顯示位置
        let x = i * TILE_SIZE - offset[0]
        let y = j * TILE_SIZE - offset[1]
        // 快取key
        let cacheKey = row + '_' + col + '_' + this.zoom
        // 記錄畫布當前需要的瓦片
        this.currentTileCache[cacheKey] = true
        // 該瓦片已加載過
        if (this.tileCache[cacheKey]) {
            // 更新到當前位置
            this.tileCache[cacheKey].updatePos(x, y).render()
        } else {
            // 未加載過
            this.tileCache[cacheKey] = new Tile({
                ctx: this.ctx,
                row,
                col,
                zoom: this.zoom,
                x,
                y,
                // 判斷瓦片是否在當前畫布快取物件上,是的話則代表需要渲染
                shouldRender: (key) => {
                    return this.currentTileCache[key]
                },
            })
        }
    }
}

效果如下:

whbm.gif

可以看到,拖動已經正常了,當然,上述實作還是很粗糙的,需要優化的地方很多,比如:

1.一般會先排個序,優先加載中心瓦片

2.快取的瓦片越來越多肯定也會影響性能,所以還需要一些清除策略

這些問題有興趣的可以自行思考,

縮放

拖動是實時更新中心點經緯度,那么縮放自然更新縮放層級就行了:

export default {
    data() {
        return {
            // 縮放層級范圍
            minZoom: 3,
            maxZoom: 18,
            // 防抖定時器
            zoomTimer: null
        }
    },
    mounted() {
        window.addEventListener('wheel', this.onMousewheel)
    },
    methods: {
        // 滑鼠滾動
        onm ousewheel(e) {
            if (e.deltaY > 0) {
                // 層級變小
                if (this.zoom > this.minZoom) this.zoom--
            } else {
                // 層級變大
                if (this.zoom < this.maxZoom) this.zoom++
            }
            // 加個防抖,防止快速滾動加載中間程序的瓦片
            this.zoomTimer = setTimeout(() => {
                this.clear()
                this.renderTiles()
            }, 300)
        }
    }
}

效果如下:

whbm.gif

功能是有了,不過效果很一般,因為我們平常使用的地圖縮放都是有一個放大或縮小的過渡影片,而這個是直接空白然后重新渲染,不仔細看都不知道是放大還是縮小,

所以我們不妨加個過渡效果,當我們滑鼠滾動后,先將畫布放大或縮小,影片結束后再根據最終的縮放值來渲染需要的瓦片,

畫布默認縮放值為1,放大則在此基礎上乘以2倍,縮小則除以2,然后影片到目標值,影片期間設定畫布的縮放值及清慷訓布,重新繪制畫布上的已有瓦片,達到放大或縮小的視覺效果,影片結束后再呼叫renderTiles重新渲染最終縮放值需要的瓦片,

// 影片使用popmotion庫,https://popmotion.io/
import { animate } from 'popmotion'

export default {
    data() {
        return {
            lastZoom: 0,
            scale: 1,
            scaleTmp: 1,
            playback: null,
        }
    },
    methods: {
        // 滑鼠滾動
        onm ousewheel(e) {
            if (e.deltaY > 0) {
                // 層級變小
                if (this.zoom > this.minZoom) this.zoom--
            } else {
                // 層級變大
                if (this.zoom < this.maxZoom) this.zoom++
            }
            // 層級未發生改變
            if (this.lastZoom === this.zoom) {
                return
            }
            this.lastZoom = this.zoom
            // 更新縮放比例,也就是目標縮放值
            this.scale *= e.deltaY > 0 ? 0.5 : 2
            // 停止上一次影片
            if (this.playback) {
                this.playback.stop()
            }
            // 開啟影片
            this.playback = animate({
                from: this.scaleTmp,// 當前縮放值
                to: this.scale,// 目標縮放值
                onUpdate: (latest) => {
                    // 實時更新當前縮放值
                    this.scaleTmp = latest
                    // 保存畫布之前狀態,原因有二:
                    // 1.scale方法是會在之前的狀態上疊加的,比如初始是1,第一次執行scale(2,2),第二次執行scale(3,3),最終縮放值不是3,而是6,所以每次縮放完就恢復狀態,那么就相當于每次都是從初始值1開始縮放,效果就對了
                    // 2.保證縮放效果只對重新渲染已有瓦片生效,不會對最后的renderTiles()造成影響
                    this.ctx.save()
                    this.clear()
                    this.ctx.scale(latest, latest)
                    // 重繪當前畫布上的瓦片
                    Object.keys(this.currentTileCache).forEach((tile) => {
                        this.tileCache[tile].render()
                    })
                    // 恢復到畫布之前狀態
                    this.ctx.restore()
                },
                onComplete: () => {
                    // 影片完成后將縮放值重置為1
                    this.scale = 1
                    this.scaleTmp = 1
                    // 根據最終縮放值重新計算需要的瓦片并渲染
                    this.renderTiles()
                },
            })
        }
    }
}

效果如下:

2022-01-13-16-23-27.gif

雖然效果還是一般,不過至少能看出來是在放大還是縮小,

坐標系轉換

前面還遺留了一個小問題,即我們把高德工具上選出的經緯度直接當做4326經緯度,前面也講過,它們之間是存在偏移的,比如手機GPS獲取到的經緯度一般都是84坐標,直接在高德地圖顯示,會發現和你實際位置不一樣,所以就需要進行一個轉換,有一些工具可以幫你做些事情,比如Gcoord、coordtransform等,

總結

上述效果看著比較一般,其實只要在上面的基礎上稍微加一點瓦片的淡出影片,效果就會好很多,目前一般都是使用canvas來渲染2D地圖,如果自己實作影片不太方便,也有一些強大的canvas庫可以選擇,筆者最后使用Konva.js庫重做了一版,加入了瓦片淡出影片,最終效果如下:

whbm.gif

另外只要搞清楚各個地圖的瓦片規則,就能稍加修改支持更多的地圖瓦片:

whbm.gif

具體實作限于篇幅不再展開,有興趣的可以閱讀本文原始碼,

本文詳細的介紹了一個簡單的web地圖開發程序,上述實作原理僅是筆者的個人思路,不代表openlayers等框架的原理,因為筆者也是GIS的初學者,所以難免會有問題,或更好的實作,歡迎指出,

在線demo:https://wanglin2.github.io/web_map_demo/

完整原始碼:https://github.com/wanglin2/web_map_demo

轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/415303.html

標籤:其他

上一篇:Javascript——ES6( ECMAScript 6.0)語法

下一篇:為什么用戶定義型別別的靜態和區域變數的合成默認建構式的行為不同?

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • IEEE1588PTP在數字化變電站時鐘同步方面的應用

    IEEE1588ptp在數字化變電站時鐘同步方面的應用 京準電子科技官微——ahjzsz 一、電力系統時間同步基本概況 隨著對IEC 61850標準研究的不斷深入,國內外學者提出基于IEC61850通信標準體系建設數字化變電站的發展思路。數字化變電站與常規變電站的顯著區別在于程序層傳統的電流/電壓互 ......

    uj5u.com 2020-09-10 03:51:52 more
  • HTTP request smuggling CL.TE

    CL.TE 簡介 前端通過Content-Length處理請求,通過反向代理或者負載均衡將請求轉發到后端,后端Transfer-Encoding優先級較高,以TE處理請求造成安全問題。 檢測 發送如下資料包 POST / HTTP/1.1 Host: ac391f7e1e9af821806e890 ......

    uj5u.com 2020-09-10 03:52:11 more
  • 網路滲透資料大全單——漏洞庫篇

    網路滲透資料大全單——漏洞庫篇漏洞庫 NVD ——美國國家漏洞庫 →http://nvd.nist.gov/。 CERT ——美國國家應急回應中心 →https://www.us-cert.gov/ OSVDB ——開源漏洞庫 →http://osvdb.org Bugtraq ——賽門鐵克 →ht ......

    uj5u.com 2020-09-10 03:52:15 more
  • 京準講述NTP時鐘服務器應用及原理

    京準講述NTP時鐘服務器應用及原理京準講述NTP時鐘服務器應用及原理 安徽京準電子科技官微——ahjzsz 北斗授時原理 授時是指接識訓通過某種方式獲得本地時間與北斗標準時間的鐘差,然后調整本地時鐘使時差控制在一定的精度范圍內。 衛星導航系統通常由三部分組成:導航授時衛星、地面檢測校正維護系統和用戶 ......

    uj5u.com 2020-09-10 03:52:25 more
  • 利用北斗衛星系統設計NTP網路時間服務器

    利用北斗衛星系統設計NTP網路時間服務器 利用北斗衛星系統設計NTP網路時間服務器 安徽京準電子科技官微——ahjzsz 概述 NTP網路時間服務器是一款支持NTP和SNTP網路時間同步協議,高精度、大容量、高品質的高科技時鐘產品。 NTP網路時間服務器設備采用冗余架構設計,高精度時鐘直接來源于北斗 ......

    uj5u.com 2020-09-10 03:52:35 more
  • 詳細解讀電力系統各種對時方式

    詳細解讀電力系統各種對時方式 詳細解讀電力系統各種對時方式 安徽京準電子科技官微——ahjzsz,更多資料請添加VX 衛星同步時鐘是我京準公司開發研制的應用衛星授時時技術的標準時間顯示和發送的裝置,該裝置以M國全球定位系統(GLOBAL POSITIONING SYSTEM,縮寫為GPS)或者我國北 ......

    uj5u.com 2020-09-10 03:52:45 more
  • 如何保證外包團隊接入企業內網安全

    不管企業規模的大小,只要企業想省錢,那么企業的某些服務就一定會采用外包的形式,然而看似美好又經濟的策略,其實也有不好的一面。下面我通過安全的角度來聊聊使用外包團的安全隱患問題。 先看看什么服務會使用外包的,最常見的就是話務/客服這種需要大量重復性、無技術性的服務,或者是一些銷售外包、特殊的職能外包等 ......

    uj5u.com 2020-09-10 03:52:57 more
  • PHP漏洞之【整型數字型SQL注入】

    0x01 什么是SQL注入 SQL是一種注入攻擊,通過前端帶入后端資料庫進行惡意的SQL陳述句查詢。 0x02 SQL整型注入原理 SQL注入一般發生在動態網站URL地址里,當然也會發生在其它地發,如登錄框等等也會存在注入,只要是和資料庫打交道的地方都有可能存在。 如這里http://192.168. ......

    uj5u.com 2020-09-10 03:55:40 more
  • [GXYCTF2019]禁止套娃

    git泄露獲取原始碼 使用GET傳參,引數為exp 經過三層過濾執行 第一層過濾偽協議,第二層過濾帶引數的函式,第三層過濾一些函式 preg_replace('/[a-z,_]+\((?R)?\)/', NULL, $_GET['exp'] (?R)參考當前正則運算式,相當于匹配函式里的引數 因此傳遞 ......

    uj5u.com 2020-09-10 03:56:07 more
  • 等保2.0實施流程

    流程 結論 ......

    uj5u.com 2020-09-10 03:56:16 more
最新发布
  • 使用Django Rest framework搭建Blog

    在前面的Blog例子中我們使用的是GraphQL, 雖然GraphQL的使用處于上升趨勢,但是Rest API還是使用的更廣泛一些. 所以還是決定回到傳統的rest api framework上來, Django rest framework的官網上給了一個很好用的QuickStart, 我參考Qu ......

    uj5u.com 2023-04-20 08:17:54 more
  • 記錄-new Date() 我忍你很久了!

    這里給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 大家平時在開發的時候有沒被new Date()折磨過?就是它的諸多怪異的設定讓你每每用的時候,都可能不小心踩坑。造成程式意外出錯,卻一下子找不到問題出處,那叫一個煩透了…… 下面,我就列舉它的“四宗罪”及應用思考 可惡的四宗罪 1. Sa ......

    uj5u.com 2023-04-20 08:17:47 more
  • 使用Vue.js實作文字跑馬燈效果

    實作文字跑馬燈效果,首先用到 substring()截取 和 setInterval計時器 clearInterval()清除計時器 效果如下: 實作代碼如下: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta ......

    uj5u.com 2023-04-20 08:12:31 more
  • JavaScript 運算子

    JavaScript 運算子/運算子 在 JavaScript 中,有一些運算子可以使代碼更簡潔、易讀和高效。以下是一些常見的運算子: 1、可選鏈運算子(optional chaining operator) ?.是可選鏈運算子(optional chaining operator)。?. 可選鏈操 ......

    uj5u.com 2023-04-20 08:02:25 more
  • CSS—相對單位rem

    一、概述 rem是一個相對長度單位,它的單位長度取決于根標簽html的字體尺寸。rem即root em的意思,中文翻譯為根em。瀏覽器的文本尺寸一般默認為16px,即默認情況下: 1rem = 16px rem布局原理:根據CSS媒體查詢功能,更改根標簽的字體尺寸,實作rem單位隨螢屏尺寸的變化,如 ......

    uj5u.com 2023-04-20 08:02:21 more
  • 我的第一個NPM包:panghu-planebattle-esm(胖虎飛機大戰)使用說明

    好家伙,我的包終于開發完啦 歡迎使用胖虎的飛機大戰包!! 為你的主頁添加色彩 這是一個有趣的網頁小游戲包,使用canvas和js開發 使用ES6模塊化開發 效果圖如下: (覺得圖片太sb的可以自己改) 代碼已開源!! Git: https://gitee.com/tang-and-han-dynas ......

    uj5u.com 2023-04-20 08:01:50 more
  • 如何在 vue3 中使用 jsx/tsx?

    我們都知道,通常情況下我們使用 vue 大多都是用的 SFC(Signle File Component)單檔案組件模式,即一個組件就是一個檔案,但其實 Vue 也是支持使用 JSX 來撰寫組件的。這里不討論 SFC 和 JSX 的好壞,這個仁者見仁智者見智。本篇文章旨在帶領大家快速了解和使用 Vu ......

    uj5u.com 2023-04-20 08:01:37 more
  • 【Vue2.x原始碼系列06】計算屬性computed原理

    本章目標:計算屬性是如何實作的?計算屬性快取原理以及洋蔥模型的應用?在初始化Vue實體時,我們會給每個計算屬性都創建一個對應watcher,我們稱之為計算屬性watcher ......

    uj5u.com 2023-04-20 08:01:31 more
  • http1.1與http2.0

    一、http是什么 通俗來講,http就是計算機通過網路進行通信的規則,是一個基于請求與回應,無狀態的,應用層協議。常用于TCP/IP協議傳輸資料。目前任何終端之間任何一種通信方式都必須按Http協議進行,否則無法連接。tcp(三次握手,四次揮手)。 請求與回應:客戶端請求、服務端回應資料。 無狀態 ......

    uj5u.com 2023-04-20 08:01:10 more
  • http1.1與http2.0

    一、http是什么 通俗來講,http就是計算機通過網路進行通信的規則,是一個基于請求與回應,無狀態的,應用層協議。常用于TCP/IP協議傳輸資料。目前任何終端之間任何一種通信方式都必須按Http協議進行,否則無法連接。tcp(三次握手,四次揮手)。 請求與回應:客戶端請求、服務端回應資料。 無狀態 ......

    uj5u.com 2023-04-20 08:00:32 more