一次在小程序中使用leaflet的经验总结

2,336 阅读12分钟

产品介绍

这是一个停车场导航系统,旨在利用自研图像识别算法实现室内导航。(就是在停车场放很多个摄像头,可以根据车牌号跟踪车的位置、根据用户点选的车位号抓取附近的行人、获取剩余空闲车位信息等。当然算法小组还提供路线规划支持,导航实时定位和提示支持等)

  • 非导航部分有首页、个人中心、车辆管理、账号设置
  • 导航部分有两个场景(车找车位和人找车)

技术选型

1. uniapp √ or 微信小程序原生开发

因为考虑到项目很紧,而uniapp可以使用vue开发,所以虽然只需要做微信小程序端,但是还是选择使用uniapp

问题:微信开发者工具打开uniapp打包好的文件夹运行后虽然可以进入断点,但代码都是经过压缩的,导致断点调试很不方便。所以开始阶段我是以h5的形式开发,但毕竟代码运行的宿主环境不一样,多少都会有些出入(比如微信小程序没有windowdocument对象,image标签也基本不一样,wxss不支持一些较高级的选择器)。所以后面我还是使用微信开发者工具预览(热更新真的很慢),使用console来调试。

2. 使用开发模板 √ or 自己搭建

还是考虑到时间紧,直接使用了最近star数上涨还不错的 unibest 开发模板,期间还给作者贡献了2个PR。

问题:因为这个开源模版刚起步,还不成熟,会遇到一些问题,虽然作者也在积极维护,但毕竟我用的太早了,导致后续修复和优化不能够同步过来。

3. 地图库选择Leaflet √

产品设计中最开始想要的是三维的地图展示,可以看到大厦及其地下的停车场,立柱和车辆都是立体的,这涉及到3D建模

优点缺点
Mapbox GL支持3D地图和复杂的视觉效果,可以自定义地图数据和样式。Mapbox的3D功能非常强大,适合创建详细的室内地图和导航功能。需要Mapbox账户和可能的成本,以及较高的学习曲线。
CesiumJS专门为3D地图设计,支持WebGL渲染。CesiumJS可以显示全球尺度的3D地形、建筑和室内结构,非常适合需要高精度和大规模3D视图的应用。相较于2D地图库,CesiumJS的学习成本和性能要求更高。
ArcGIS API for JavaScript支持3D视图和多种数据源,是一个全功能的地理信息系统(GIS)解决方案。ArcGIS提供了强大的工具和API来创建复杂的室内和室外地图。可能涉及到许可费用,且系统较为复杂,适用于有一定GIS背景的开发者。
Three.js如果你希望从头开始构建一个完全定制的3D环境,Three.js提供了底层的3D渲染能力。它允许开发者使用WebGL创建详细的3D场景和动画。不是一个专门的地图库,需要更多的开发工作来集成地图数据和导航功能。

以上无论怎么选择都需要比较高的学习成本,别说开发了,数据采集就需要一段时间。后来决定先做一个2D地图导航:

优点缺点Star包大小
Leaflet轻量级,容易上手,支持使用图片作为地图图层。Leaflet的简单API和丰富的插件生态使其成为处理较简单地图功能的理想选择。主要用于处理地理地图,对于纯粹的图像地图可能需要进行一些额外的定制。39.9k3.74MB
OpenLayers功能全面,支持各种图层类型,包括使用单一图像作为地图。OpenLayers能够处理复杂的交互和图层堆叠,适合于需要更多定制和交互功能的场景。相比Leaflet,它的包体积更大,配置也更复杂。10.9K10.6MB
OpenSeadragon如果地图图像非常大且需要支持平滑的缩放和拖动,OpenSeadragon是一个专门用于高分辨率图像展示的库,支持无损放大和视图控制。主要专注于图像的展示而不是地图功能,可能需要额外开发工作来添加地图特有的交互。\\
自定义HTML5 Canvas如果你的需求非常特定,例如需要精确控制每个像素点的交互,使用HTML5的Canvas API可以完全自定义绘图和事件处理。需要从头开始开发所有的功能,包括缩放、拖动和位置标记等。\\

总的来说:

  • 如果你需要一个简单的解决方案,可以快速实现,并且支持基本的地图交互,Leaflet 是一个很好的选择。它简单,容易集成,并且可以通过插件进行扩展。
  • 如果你的应用需要处理更复杂的图层或者需要更高级的地图控制功能OpenLayers 提供了更多的灵活性和配置选项。
  • 对于高分辨率的详细图像展示,考虑使用 OpenSeadragon
  • 如果你需要完全控制图形和交互,并且不介意自己编写更多的代码,HTML5 Canvas 可能是最合适的。

综合考虑,选择了免费、轻量级、易上手、有大量社区插件leaflet

4. 地图相关页面使用h5开发,通过 webview 嵌入到小程序中

因为leaflet需要 浏览器 的环境,所以地图需要使用webview来嵌入h5页面。那到底哪一部分使用webview呢?有以下几种方案:

  • 方案1: 地图内容使用webview搜索框控件覆盖webview上;
  • 方案2: 整个小程序就一个webview,其余都用h5写,做一个完全套壳的小程序;
  • 方案3: 涉及到地图的页面使用webview,其他页面正常开发。

经过不断得踩坑,列表一下关于小程序webview的注意点:

  1. webview自动铺满整个小程序页面,因此方案1可以pass;
  2. webview的页面无法自定义导航栏,而从设计稿上看是需要自定义导航栏的,因此方案2悬了。
  3. 小程序和webview之间不支持除 JSSDK 提供的接口之外的通信,并且webview网页向小程序 postMessage 时,只会在以下特定时机触发并收到消息:小程序后退、组件销毁、分享、复制链接。这意味着方案3可能会遇到通信上的困难。

尝试后发现套壳小程序会有以下问题:

  1. 导航栏问题:不能自定义导航栏意味着,屏幕上方永远有一个原生导航栏的占位,只是用来展示胶囊按钮。无论webview网页叠了多少层页面原生导航栏左上方都没有返回按钮,因为对小程序来说只是一个页面。
  2. 用户体验问题:没有原生小程序那样流畅的页面切换动画效果
  3. 页面栈问题:小程序有现成的页面栈机制,而如果选择使用vue单页应用需要自己实现,或者使用相关插件如:vue-page-stackvue-stack-router或者vue-navigation

最终决定采用方案3:只有涉及到地图的页面用webview套h5:

image.png

5. leaflet插件

leaflet-polylinedecorator 画线上的箭头

代码:

// 绘制路线
let polyline = L.polyline(routeCoordinates, options)

// 使用PolylineDecorator来放置箭头
let polylineDecorator = L.polylineDecorator(polyline, {
    patterns: [
        {
            offset: '0%',  // 从路径的起点开始
            repeat: '32px',
            symbol: L.Symbol.arrowHead({
                pixelSize: 6, // 箭头大小
                polygon: false,
                headAngle: 60, // 箭头角度
                pathOptions: {
                    stroke: true,
                    weight: 2, // 箭头线条宽度
                    color: '#ffffff' // 箭头颜色
                }
            })
        }
    ]
})

效果:

image.png

leaflet-rotate 来旋转地图和marker

相关issue:github.com/Raruto/leaf…

import 'leaflet-rotate'

// 旋转地图
L.map('map', {
        crs: L.CRS.Simple,
        minZoom: -3,
        maxZoom: -2,
        zoomControl: false,
        attributionControl: false,
        rotateControl: false,
        rotate: true,
        bearing: 90, // 顺时针旋转90°
    });

// 旋转marker:
marker.setRotation?.(calcAngle) // Rad数据

难点攻关

1. 根据前后两点计算方位角

export function getAngle(p1: [number, number], p2: [number, number]) {
    
    // 设置marker的旋转方向
    let dx = p1[0] - p2[0]
    let dy = p2[1] - p1[1] // 因为向下为y+,向右为x+,所以需要反过来减
    let calcAngle = 0  // rad
    calcAngle = -Math.atan2(dy, dx) // 这里坐标系对称,所以这里再次取反
    // console.log(p1, p2, dx, dy, calcAngle)
    return calcAngle
}

使用:

let calcAngle = getAngle(markerRecord[0], markerRecord[1])
marker.setRotation?.(calcAngle)

2. 根据给定的几个点画最小覆盖圆

function getCircleFromTwoPoints(p1, p2) {
    const center = {
        x: (p1.x + p2.x) / 2,
        y: (p1.y + p2.y) / 2
    };
    const radius = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)) / 2;
    return { center, radius };
}

function isPointInsideCircle(point, circle) {
    const distance = Math.sqrt(Math.pow(point.x - circle.center.x, 2) + Math.pow(point.y - circle.center.y, 2));
    return distance <= circle.radius + 0.0000000001;
}

function getCircleFromThreePoints(p1, p2, p3) {
    const offset = Math.pow(p2.x, 2) + Math.pow(p2.y, 2);
    const bc = (Math.pow(p1.x, 2) + Math.pow(p1.y, 2) - offset) / 2;
    const cd = (offset - Math.pow(p3.x, 2) - Math.pow(p3.y, 2)) / 2;
    const det = (p1.x - p2.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p2.y);

    if (Math.abs(det) < 1e-10) return null; // 三点共线,无法构成圆

    const idet = 1 / det;

    const center = {
        x: (bc * (p2.y - p3.y) - cd * (p1.y - p2.y)) * idet,
        y: (cd * (p1.x - p2.x) - bc * (p2.x - p3.x)) * idet
    };

    const radius = Math.sqrt(Math.pow(p1.x - center.x, 2) + Math.pow(p1.y - center.y, 2));

    return { center, radius };
}

export function findMinimumCircle(points: { x: number, y: number }[]): {
    center: { x: number, y: number }
    radius: number
} {
    if (points.length === 2) {
        // 对于只有两个点的特殊情况,直接返回通过这两点的最小圆
        return getCircleFromTwoPoints(points[0], points[1]);
    }

    let minimumCircle = null;

    // 遍历所有三点组合以找到可能的圆
    for (let i = 0; i < points.length; i++) {
        for (let j = i + 1; j < points.length; j++) {
            // 对于每一对点,计算最小圆,并检查是否是当前最小的
            const circleFromTwoPoints = getCircleFromTwoPoints(points[i], points[j]);
            let allInsideTwoPoints = true;
            for (const point of points) {
                if (!isPointInsideCircle(point, circleFromTwoPoints)) {
                    allInsideTwoPoints = false;
                    break;
                }
            }
            if (allInsideTwoPoints) {
                if (minimumCircle === null || circleFromTwoPoints.radius < minimumCircle.radius) {
                    minimumCircle = circleFromTwoPoints;
                }
            }

            for (let k = j + 1; k < points.length; k++) {
                const tempCircle = getCircleFromThreePoints(points[i], points[j], points[k]);
                if (tempCircle) {
                    let allInside = true;
                    // 检查所有点是否在圆内
                    for (const point of points) {
                        if (!isPointInsideCircle(point, tempCircle)) {
                            allInside = false;
                            break;
                        }
                    }
                    // 如果所有点都在圆内,检查是否是迄今为止的最小圆
                    if (allInside) {
                        if (minimumCircle === null || tempCircle.radius < minimumCircle.radius) {
                            minimumCircle = tempCircle;
                        }
                    }
                }
            }
        }
    }

    // 返回最小的圆,如果没有找到则为null
    return minimumCircle;
}

使用:

let points = arr.map(p => ({ x: p.x, y: p.y }))
let minimumCircle = findMinimumCircle(points)
if (minimumCircle) {
    const { center, radius } = minimumCircle
    L.circle([center.x, center.y], {
      color: 'rgba(30, 120, 255, 0.18)',
      fillOpacity: 1,
      radius: radius + 20
    }).addTo(mapRef.value?.map)
}

3. 在由4个点连成的四边形中,找到连线最长的2个点

/** 在4个点中,找到距离最长的2个点 */
export function getLongestTwoPoints(points: [number, number][]) {
    let maxDistance = 0;
    let longestPoints: [[number, number], [number, number]] = [points[0], points[1]];
    for (let i = 0; i < points.length - 1; i++) {
        const p1 = points[i];
        const p2 = points[i + 1];
        const distance = getDistance(p1, p2);
        if (distance > maxDistance) {
            maxDistance = distance;
            longestPoints = [p1, p2];
        }
    }
    return longestPoints;
}

使用:

let longestTwoPoints = getLongestTwoPoints(location)
let angle = getAngle(...longestTwoPoints)
// carMarker.setRotation?.(angle)

carMarker = L.marker(center, {
icon: L.divIcon({
  className: 'cur-pos-icon',
  iconSize: [20, 20],
  html: `
    <div style="position: relative;display:flex;align-items:center;justify-content:center;height:100%;">
      <img class="my-car-img" style="width: ${zoom === -3 ? 40 : 70}px;transform: rotate(${angle}rad)" src="${myCarIcon}">
      <img style="position: absolute;top:50%;left:50%;transform: translate(-50%, calc(-50% - 20px));" src="${endSvg}">
    </div>
  `,
})
}).addTo(mapRef.value.map)

4. 根据一个点将1条线分割成2条线

function getClosestPointOnSegment(p, a, b) {
    const atob = { x: b.x - a.x, y: b.y - a.y };
    const atop = { x: p.x - a.x, y: p.y - a.y };
    const len = atob.x * atob.x + atob.y * atob.y;
    let dot = atop.x * atob.x + atop.y * atob.y;
    const t = Math.min(1, Math.max(0, dot / len));
    dot = (atob.x * atob.y - atop.x * atop.y) * (atob.x * atob.y - atop.x * atop.y);
    const closest = { x: a.x + atob.x * t, y: a.y + atob.y * t };
    return closest;
}

function getDistance(a, b) {
    return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
}

function findClosestPointOnPathAndSplit(p: { x: number, y: number }, path: LinePointsType) {
    let closestPoint = { x: path[0].x, y: path[0].y }; // 设置初始值以防循环中没有找到点
    let minDistance = Infinity;
    let splitIndex = 0; // 初始化splitIndex以避免负值

    for (let i = 0; i < path.length - 1; i++) {
        const a = { x: path[i].x, y: path[i].y };
        const b = { x: path[i + 1].x, y: path[i + 1].y };
        const closest = getClosestPointOnSegment(p, a, b);
        const distance = getDistance(p, closest);
        if (distance < minDistance) {
            minDistance = distance;
            closestPoint = closest;
            splitIndex = i; // 记录最近点所在的线段索引
        }
    }
    let path1 = path.slice(0, splitIndex + 1);
    let path2 = path.slice(splitIndex + 1);

    return [path1, path2]
}

5. 解决css的tranform rotate 从350°旋转至10°转了一大圈的问题

后端只会返回0~360的角度数值,但实际上这种情况我们需要将10°转换成370°。至于370°怎么来的,看代码备注:

// useAngle.ts
/**
 * 角度计算
 * > 180 : -
 * 0 - 180 : +
 * -180 - 0 : -
 * < -180 : +
 */
export default () => {

    let accAngle: number,
        oldAngle = 0,
        newAngle = 0;
    function getAccAngle(angle: number) {

        if (typeof angle !== 'number') return angle;

        oldAngle = newAngle;
        newAngle = angle;
        if (accAngle === undefined) {
            accAngle = newAngle
        } else {
            let diff = newAngle - oldAngle;
            let absDiff = Math.abs(diff);
            if (diff >= 180) {
                accAngle -= 360 - absDiff;
            } else if ((diff >= 0 && diff < 180)) {
                accAngle += absDiff;
            } else if (diff < 0 && diff > -180) {
                accAngle -= absDiff;
            } else if (diff <= -180) {
                accAngle += 360 - absDiff;
            }
        }
        return accAngle
    }

    return {
        getAccAngle
    };
}
const { getAccAngle } = useAngle()

function drawCurrentMarker(config: {
    x: number
    y: number
    angle?: number
    setCenter: boolean
} = { x: 0, y: 0, setCenter: false }) {
    const { x, y, angle, setCenter } = config

    if (marker) {
        smoothTransition(marker, [x, y]);

        // 将其设置为地图中心点
        if (setCenter) {
            map.value.setView([x, y])
        }
        if (typeof angle === 'number') {
            let _angle = getAccAngle(angle)

            // 度转弧度
            let rad = _angle * Math.PI / 180
            // @ts-ignore
            marker.setRotation?.(rad)

        } else if (markerRecord.length >= 2) {
            let calcAngle = getAngle(markerRecord[0], markerRecord[1])

            let _angle = getAccAngle(calcAngle * 180 / Math.PI)
            console.log('angle', _angle, 'calcAngle', calcAngle)
            // @ts-ignore
            marker.setRotation?.(calcAngle)
        }
    }
}

6. 解决小程序的webview向h5通信问题

我是通过修改webview的src实现小程序的webviewh5通信:

const src = ref('')

onLoad((option) => {
  src.value = `${import.meta.env.VITE_H5_BASEURL}/car-nav?${qs.stringify(option)}&timeStamp=${new Date().getTime()}`
})

onShow(() => {
  // 将src中的timeStamp替换成当前时间戳
  src.value = src.value.replace(/timeStamp=(\d+)/, `timeStamp=${new Date().getTime()}`)
})

但是这样会存在一个严重的问题:修改src会让webview的页面栈历史记录+1,导致此时返回不是小程序的上一页而是webview的上一页。

这样就需要h5页面监听页面的返回(popstate)事件来,强制让小程序返回上一页:

// uniapp开发微信小程序,A页面通过“修改webview的src的query,webview页面监听query变化”来实现A向webview通信的,但修改src会让webview的页面栈历史记录+1,导致此时返回的不是小程序的上一页而是webview的上一页
let oldHistoryLength = history.length; // 需要处理的状态
window.addEventListener('popstate', () => {
  // 虽然后面的小程序页面返回到当前页也会触发popstate,但其顺序如下:
  // onshow改变src的stamp,历史栈+1( -> watch stamp) -> 触发popstate ,因此只要新的栈长度大于旧的栈长度,就说明是B返回A(同时设置需要处理的状态oldHistoryLength = history.length),否则就是A返回首页
  if (history.length > oldHistoryLength) {
    oldHistoryLength = history.length;
  } else {
    // 处理返回,强制返回小程序的上一页
    // @ts-ignore
    wx.miniProgram.navigateBack({ delta: 1 });
  }
})

问题: 手动滑动返回时,会有连续多次返回的动画,直接点击导航栏返回是正常的。

7. websocket每隔1s获取到坐标,使用requestAnimateFrame让小车平滑移动

最初方案是将一个2个点做插值,使用setInterval分60次改变位置,但频繁的定时器时间久了会出现回调错位问题,所以采用requestAnimateFrame代替。它的优势这里不做赘述,很多文章都有写。

function smoothTransition(marker, newLatLng) {
    const oldLatLng = marker.getLatLng();
    const duration = 1000; // 动画持续时间,毫秒
    const startTime = performance.now();

    function updatePosition(currentTime) {
        const elapsedTime = currentTime - startTime;
        const progress = Math.min(elapsedTime / duration, 1);

        const lat = oldLatLng.lat + (newLatLng[0] - oldLatLng.lat) * progress;
        const lng = oldLatLng.lng + (newLatLng[1] - oldLatLng.lng) * progress;
        marker.setLatLng([lat, lng])

        if (progress < 1) {
            requestAnimationFrame(updatePosition);
        } else {
            marker.setLatLng(newLatLng)
        }
    }

    requestAnimationFrame(updatePosition);
}

duration 这里是写死的,当然数据通过网络传输最终到达前端可能时间间隔不准,所以可以获取前后2点的时间差来动态赋值。