当前位置: 首页 > article >正文

html+canvas地图画布实现快速拖动出现瓦片空白问题优化

快速拖动出现问题,慢慢拖动不会有问题”的现象,问题的根本原因可能是鼠标移动事件触发得太频繁,导致每次鼠标移动时都重新渲染瓦片,造成性能问题或者视觉上的不流畅。特别是在快速拖动时,浏览器需要处理大量的瓦片加载和渲染,这就容易出现卡顿。

先上问题代码,快速拖动出现问题,慢慢拖动不会有问题

快速拖动出现问题,慢慢拖动不会有问题
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>瓦片拖动功能</title>
    
    <style>
/*        地图容器*/
        .map {
            position: fixed;
            width: 100%;
            height: 100%;
            left: 0;
            top: 0;

        }
        /*十字架*/
        .line {
            position: absolute;
            background-color: #000;
        }
        /*十字架横轴*/
        .lineX {
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
            width: 50px;/*改成50px*/
            height: 2px;
        }
        /*十字架纵轴*/
        .lineY {
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
            height: 50px;/*改成50px*/
            width: 2px;
        }
        .clearButton{
            position: absolute;left: 10px;top: 10px;cursor: pointer;
        }
        .refreshButton{
            position: absolute;left: 90px;top: 10px;cursor: pointer;
        }
    </style>
</head>
<body>
    <div class="map" id="map">
<!--        画布-->
        <canvas id="canvas"  onmousedown="onMousedown(event)"></canvas>
<!--        十字架横轴-->
        <div class="line lineX"></div>
<!--        十字架纵轴-->
        <div class="line lineY"></div>

        <button class="clearButton" onclick="renderTiles()">重绘画布</button>
        <button class="refreshButton" onclick="onClear()">清除画布</button>
    </div>
</body>



<script>

// 角度转弧度
function angleToRad(angle){
    return angle * (Math.PI / 180)
}

// 弧度转角度
function radToAngle(rad){
    return rad * (180 / Math.PI)
}

// 地球半径  单位:米
let EARTH_RAD = 6378137

// 4326转3857
function lngLatToMercator(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
function 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]
}

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

// 获取某一层级下的分辨率     n 从0层开始
function getResolution(n){
    let tileNums = Math.pow(2, n)               //一行的瓦片数量
    console.log("n,tileNums",n,tileNums)
    let tileTotalPx = tileNums * TILE_SIZE         //一行的瓦片像素点
    return EARTH_PERIMETER / tileTotalPx           // 每像素点代表多少米
}

// 拼接瓦片地址
function 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`
}


    // 根据3857坐标及缩放层级计算瓦片行列号


    function getTileRowAndCol1 (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]
    }

    function getTileRowAndCol2 (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]
    }


    // 分辨率列表
    let resolutions = []
    for (let i = 0; i <= 18; i++) {
        resolutions.push(getResolution(i))
    }
    // 计算4326经纬度对应的像素坐标
    function getPxFromLngLat (lng, lat, z) {
        let [_x, _y] = lngLatToMercator(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]
    }



</script>

<script>

    let tileCache={}//缓存瓦片
    let currentTileCache={} //记录当前地图上的瓦片
    let center=[120.148732,30.231006]    //已知变量,中心坐标   雷峰塔坐标
    let zoom=17    //已知变量, 缩放层级
    // 容器大小

    let map=document.getElementById('map')
    let canvas=document.getElementById('canvas')
    let { width, height } = map.getBoundingClientRect()
    let isMousedown= false//是否按下鼠标
    // 设置画布大小
    canvas.width = width
    canvas.height = height
    // 获取绘图上下文
    let ctx = canvas.getContext('2d')
    // 移动画布原点到画布中间,本来画布原点是左上角的
    ctx.translate(width / 2, height / 2)
    renderTiles()


    //     绘制
    function renderTiles(){
        console.log("center=============",center)
        // 中心点对应的瓦片
        let centerTile = getTileRowAndCol2(
            ...lngLatToMercator(...center),// 4326转3857
            zoom                            // 缩放层级
        )

        // 中心瓦片左上角对应的像素坐标
        let centerTilePos = [
            centerTile[0] * TILE_SIZE,
            centerTile[1] * TILE_SIZE,
        ];

        // 中心点对应的像素坐标
        let centerPos = getPxFromLngLat(...center, zoom)

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



        // 计算瓦片数量    Math.ceil()向上取整
        let rowMinNum = Math.ceil((width / 2 - offset[0]) / TILE_SIZE)// 左
        let colMinNum = Math.ceil((height / 2 - offset[1]) / TILE_SIZE)// 上
        let rowMaxNum = Math.ceil((width / 2 - (TILE_SIZE - offset[0])) / TILE_SIZE)// 右
        let colMaxNum = Math.ceil((height / 2 - (TILE_SIZE - offset[1])) / TILE_SIZE)// 下


// 渲染画布内所有瓦片
        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 + '_' + zoom
                // 记录画布当前需要的瓦片
                currentTileCache[cacheKey] = true

                if (tileCache[cacheKey]) {
                    // 更新到当前位置
                    if(currentTileCache[cacheKey]){
                        ctx.drawImage(
                            tileCache[cacheKey],
                            x,
                            y
                        )
                    }


                }else {
                    // 加载瓦片图片
                    let img = new Image()
                    img.src = getTileUrl(
                        row,// 行号
                        col,// 列号
                        zoom
                    )

                    tileCache[cacheKey]=img
                    img.onload = () => {
                        // 只有在当前画布上的瓦片才需要渲染
                        if( currentTileCache[cacheKey]){
                            // 渲染到canvas
                            ctx.drawImage(
                                img,
                                x,
                                y
                            )
                        }


                    }


                }

            }
        }
    }

    function render(ctx,img,x,y,cacheKey){
        if (!currentTileCache[cacheKey]) {
            return
        }
        ctx.drawImage(img, x, y)
    }

    // 鼠标按下
    function onMousedown(e){

        if (e.which === 1) {
            isMousedown = true;
        }
    }


    window.addEventListener("mousemove", onMousemove);
    window.addEventListener("mouseup", onMouseup);

    // 鼠标移动
    function onMousemove(e){
        if (!isMousedown) {
            return;
        }
        // 计算本次拖动的距离对应的经纬度数据
        let mx = e.movementX * resolutions[zoom];//表示这一次鼠标事件距离上一次鼠标事件横向上的偏移量   ,基本就是 1 像素
        let my = e.movementY * resolutions[zoom];
// 把当前中心点经纬度转成3857坐标
        let [x, y] = lngLatToMercator(...center);
// 更新拖动后的中心点经纬度
        center = mercatorToLngLat(x - mx, my + y);


        onClear()
        renderTiles()

    }

    // 鼠标松开
    function onMouseup(){
        isMousedown = false;
    }

    // 清除画布
    function onClear(){

        ctx.clearRect(
            -width / 2,   //参数1 和2 是起始位置
            -height / 2,
            width,
            height
        );


    }




</script>


</html>     

建议可以通过以下几个方法来优化:

1. 防抖(Debounce)鼠标移动事件

防抖的目的是限制事件的触发频率,避免每次快速拖动都调用渲染函数,导致过多的绘制操作。可以使用 setTimeout 来做一个简单的防抖。

例如,你可以这样修改 onMousemove 函数:

let debounceTimer;

function onMousemove(e) {
    if (!isMousedown) {
        return;
    }

    // 清除上一次的定时器
    clearTimeout(debounceTimer);

    // 设置一个新的定时器,延迟50ms执行渲染操作
    debounceTimer = setTimeout(() => {
        let mx = e.movementX * resolutions[zoom];
        let my = e.movementY * resolutions[zoom];

        // 转换当前经纬度
        let [x, y] = lngLatToMercator(...center);
        center = mercatorToLngLat(x - mx, my + y);

        onClear();
        renderTiles();
    }, 50); // 可以调整50ms的延迟
}

2. 避免重复渲染

你可以检查一下是否已经绘制了某个瓦片,而不需要每次都重新绘制。例如,如果某个瓦片已经加载并显示在画布上,且位置没有变化,就不需要重新绘制它。

你可以在 renderTiles 函数中优化一下瓦片的绘制逻辑,减少不必要的绘制。

3. 限制瓦片加载并发

当快速拖动时,浏览器可能会同时请求大量的瓦片,导致性能瓶颈。你可以通过限制并发请求的数量来减少浏览器的负担。例如,可以在加载瓦片时加入一个阈值,防止一次性加载过多瓦片。

比如,你可以在 renderTiles 中加入判断,只有当瓦片超出缓存时才进行加载,避免重复加载已显示的瓦片。

4. 使用 requestAnimationFrame

requestAnimationFrame 是一种优化动画渲染的方式,它确保浏览器在下一帧绘制时才进行渲染,从而减少了不必要的重绘,提升了性能。你可以使用 requestAnimationFrame 来包裹渲染函数,这样就能确保只会在浏览器刷新时执行一次渲染操作。

比如:

let isRendering = false;

function onMousemove(e) {
    if (!isMousedown) {
        return;
    }

    if (!isRendering) {
        isRendering = true;
        requestAnimationFrame(() => {
            let mx = e.movementX * resolutions[zoom];
            let my = e.movementY * resolutions[zoom];

            let [x, y] = lngLatToMercator(...center);
            center = mercatorToLngLat(x - mx, my + y);

            onClear();
            renderTiles();

            isRendering = false;
        });
    }
}

这样,每次 mousemove 事件都会排队等待浏览器下一帧来执行,这样就不会造成频繁的渲染。

通过使用防抖技术(Debounce)、优化瓦片加载与渲染、限制并发瓦片请求以及使用 requestAnimationFrame 来优化渲染流程,可以显著提高快速拖动时的性能,避免出现拖动时的卡顿或不流畅问题。

研究发现,通过防抖并不能实际解决瓦片空白问题,又采用了如下方式:

先上可以快速拖动的代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>瓦片类拖动</title>
    <script src="MapUtil.js"></script>
    <style>
/*        地图容器*/
        .map {
            position: fixed;
            width: 100%;
            height: 100%;
            left: 0;
            top: 0;

        }
        /*十字架*/
        .line {
            position: absolute;
            background-color: #000;
        }
        /*十字架横轴*/
        .lineX {
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
            width: 50px;/*改成50px*/
            height: 2px;
        }
        /*十字架纵轴*/
        .lineY {
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
            height: 50px;/*改成50px*/
            width: 2px;
        }
        .clearButton{
            position: absolute;left: 10px;top: 10px;cursor: pointer;
        }
        .refreshButton{
            position: absolute;left: 90px;top: 10px;cursor: pointer;
        }
    </style>
</head>
<body>
    <div class="map" id="map">
<!--        画布-->
        <canvas id="canvas"  onmousedown="onMousedown(event)"></canvas>
<!--        十字架横轴-->
        <div class="line lineX"></div>
<!--        十字架纵轴-->
        <div class="line lineY"></div>

        <button class="clearButton" onclick="renderTiles()">重绘画布</button>
        <button class="refreshButton" onclick="onClear()">清除画布</button>
    </div>
</body>



<script>

    // 角度转弧度
    function angleToRad(angle){
        return angle * (Math.PI / 180)
    }

    // 弧度转角度
    function radToAngle(rad){
        return rad * (180 / Math.PI)
    }
     // 地球半径  单位:米
    let EARTH_RAD = 6378137
       // 地球周长
    let EARTH_PERIMETER = 2 * Math.PI * EARTH_RAD
 // 瓦片像素
    let TILE_SIZE = 256
    // 根据3857坐标及缩放层级计算瓦片行列号
    function getTileRowAndCol1 (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]
    }

    function getTileRowAndCol2 (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]
    }
    // 4326转3857
    function lngLatToMercator(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]
    }
  // 获取某一层级下的分辨率     n 从0层开始
    function getResolution(n){
        let tileNums = Math.pow(2, n)               //一行的瓦片数量
        console.log("n,tileNums",n,tileNums)
        let tileTotalPx = tileNums * TILE_SIZE         //一行的瓦片像素点
        return EARTH_PERIMETER / tileTotalPx           // 每像素点代表多少米
    }
    // 分辨率列表
    let resolutions = []
    for (let i = 0; i <= 18; i++) {
        resolutions.push(getResolution(i))
    }
    // 计算4326经纬度对应的像素坐标
    function getPxFromLngLat (lng, lat, z) {
        let [_x, _y] = lngLatToMercator(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]
    }

   // 拼接瓦片地址
    function 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`
    }
   // 3857转4326
    function 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]
    }


</script>

<script>


    // 单张瓦片类
    class Tile {
        constructor({ctx, row, col, zoom, x, y, shouldRender}) {

            this.ctx = ctx    // 画布上下文

            this.row = row    // 瓦片行列号
            this.col = col
            this.zoom = zoom     // 瓦片层级

            this.x = x      // 显示位置
            this.y = y

            this.shouldRender = shouldRender     // 一个函数,判断某块瓦片是否应该渲染

            this.url = getTileUrl(this.row, this.col, this.zoom)    // 瓦片url

            this.cacheKey = this.row + '_' + this.col + '_' + this.zoom      // 缓存key

            this.img = null     // 图片
            this.load()
        }

        // 加载图片
        load() {
            this.img = new Image()
            this.img.src = this.url
            this.img.onload = () => {
                this.render()
            }
        }

        // 将图片渲染到canvas上
        render() {
            if (!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
        }
    }

    // 初始化画布
    let map=document.getElementById('map')
    let canvas=document.getElementById('canvas')
    let { width, height } = map.getBoundingClientRect()
    // 设置画布大小
    canvas.width = width
    canvas.height = height
    // 获取绘图上下文
    let ctx = canvas.getContext('2d')
    // 移动画布原点到画布中间,本来画布原点是左上角的
    ctx.translate(width / 2, height / 2)
    let tileCache={}//缓存瓦片
    let currentTileCache={} //记录当前地图上的瓦片
    let center=[120.148732,30.231006]    //已知变量,中心坐标   雷峰塔坐标
    let zoom=17    //已知变量, 缩放层级
    // 容器大小

    let isMousedown= false//是否按下鼠标

    renderTiles()


    //     绘制
    function renderTiles(){
        console.log("center=============",center)
        // 中心点对应的瓦片
        let centerTile = getTileRowAndCol2(
            ...lngLatToMercator(...center),// 4326转3857
            zoom                            // 缩放层级
        )

        // 中心瓦片左上角对应的像素坐标
        let centerTilePos = [
            centerTile[0] * TILE_SIZE,
            centerTile[1] * TILE_SIZE,
        ];

        // 中心点对应的像素坐标
        let centerPos = getPxFromLngLat(...center, zoom)

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



        // 计算瓦片数量    Math.ceil()向上取整
        let rowMinNum = Math.ceil((width / 2 - offset[0]) / TILE_SIZE)// 左
        let colMinNum = Math.ceil((height / 2 - offset[1]) / TILE_SIZE)// 上
        let rowMaxNum = Math.ceil((width / 2 - (TILE_SIZE - offset[0])) / TILE_SIZE)// 右
        let colMaxNum = Math.ceil((height / 2 - (TILE_SIZE - offset[1])) / TILE_SIZE)// 下


// 渲染画布内所有瓦片
        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 + '_' + zoom
                // 记录画布当前需要的瓦片
                currentTileCache[cacheKey] = true

                if (tileCache[cacheKey]) {
                    // 更新到当前位置
                    tileCache[cacheKey].updatePos(x, y).render();

                }else {
                    // 加载瓦片图片
                    tileCache[cacheKey] = new Tile({
                        ctx: ctx,
                        row,
                        col,
                        zoom: zoom,
                        x,
                        y,
                        // 判断瓦片是否在当前画布缓存对象上,是的话则代表需要渲染
                        shouldRender: (key) => {
                            return currentTileCache[key];
                        },
                    });

                }

            }
        }
    }



    // 鼠标按下
    function onMousedown(e){

        if (e.which === 1) {
            isMousedown = true;
        }
    }


    window.addEventListener("mousemove", onMousemove);
    window.addEventListener("mouseup", onMouseup);

    // 鼠标移动
    function onMousemove(e){
        if (!isMousedown) {
            return;
        }
        // 计算本次拖动的距离对应的经纬度数据
        let mx = e.movementX * resolutions[zoom];//表示这一次鼠标事件距离上一次鼠标事件横向上的偏移量   ,基本就是 1 像素
        let my = e.movementY * resolutions[zoom];
// 把当前中心点经纬度转成3857坐标
        let [x, y] = lngLatToMercator(...center);
// 更新拖动后的中心点经纬度
        center = mercatorToLngLat(x - mx, my + y);


        onClear()
        renderTiles()

    }

    // 鼠标松开
    function onMouseup(){
        isMousedown = false;
    }

    // 清除画布
    function onClear(){

        ctx.clearRect(
            -width / 2,   //参数1 和2 是起始位置
            -height / 2,
            width,
            height
        );


    }




</script>


</html>


http://www.kler.cn/a/541525.html

相关文章:

  • 【deepseek-r1本地部署】
  • 自动化xpath定位元素(附几款浏览器xpath插件)
  • 高阶C语言|枚举与联合
  • pytest生成报告no tests ran in 0.01s
  • Docker 部署 MySQL-5.7 单机版
  • 【python】matplotlib(animation)
  • 网络安全溯源 思路 网络安全原理
  • cppcheck静态扫描代码是否符合MISRA-C 2012规范
  • 1 推荐系统概述
  • 重启电脑之后vscode不见了
  • HTTP协议学习大纲
  • vLLM 安装记录 (含踩坑xformers)
  • #渗透测试#批量漏洞挖掘#ServiceNow UI Jelly模板注入(CVE-2024-4879)
  • 更换网络IP地址几种简单的方法
  • 计算机毕业设计SpringBoot+Vue.js+H5在线花店 花店app 鲜花销售系统 网上花店(app+web)(源码+文档+运行视频+讲解视频)
  • java后端开发day11--综合练习(二)
  • 【模型部署】大模型部署工具对比:SGLang, Ollama, VLLM, LLaMA.cpp如何选择?
  • linux tcpdump文件分割
  • 【Vue】3.0利用远程仓库自定义项目脚手架
  • SPI机制:Java SPI原理及源码剖析、应用场景分析与自实现案例实战详解
  • linux利用nfs服务器,实现数据和windows环境拷贝
  • HTML之JavaScript分支结构
  • 127,【3】 buuctf [NPUCTF2020]ReadlezPHP
  • Redis 数据类型 String 字符串
  • 【linux学习指南】模拟线程封装与智能指针shared_ptr
  • 高级java每日一道面试题-2025年02月01日-框架篇[SpringBoot篇]-Spring Boot 的核心配置文件有哪几个?它们的区别是什么?