Bootstrap

微信小程序地图Map结合canvas实现手动绘制地图区域

1. 功能概述

在微信小程序中,用户手动在地图上绘制区域,将绘制的区域边界点转换为经纬度在地图上显示绘制的区域。此功能实现了用户与地图的交互,可以应用于地理围栏、区域标记等场景。

2. 实现步骤

2.1 获取用户位置

在小程序加载时,使用 wx.getLocation 获取用户的当前位置,并设置地图的初始经纬度。

Page({
  data: {
    longitude: 0,
    latitude: 0,
    polygons: [] as Array<{
      points: Array<{ longitude: number; latitude: number }>;
    }>,
    drawing: false,
    canvasPoints: [] as Array<{ x: number; y: number }>,
    screenWidth: 0,
    screenHeight: 0,
    mapScale: 14,
  },

  onLoad() {
    const that = this;
    wx.getLocation({
      type: "gcj02",
      success(res) {
        that.setData({
          longitude: res.longitude,
          latitude: res.latitude,
        });
      },
    });

    const systemInfo = wx.getSystemInfoSync();
    this.setData({
      screenWidth: systemInfo.windowWidth,
      screenHeight: systemInfo.windowHeight,
    });

    wx.startLocationUpdate({
      success() {
        wx.onLocationChange((location) => {
          if (!that.data.drawing) {
            const { longitude, latitude } = location;
            that.setData({ longitude, latitude });
          }
        });
      },
    });
  },

2.2 用户绘制区域

通过在地图上覆盖一层透明的Canvas,用户可以在Canvas上绘制区域。在绘制过程中,记录触摸点的坐标。

  handleCanvasTouchStart() {
    this.setData({ drawing: true, canvasPoints: [] });
  },

  handleCanvasTouchMove(event: any) {
    if (this.data.drawing) {
      const { x, y } = event.touches[0];
      const { canvasPoints } = this.data;
      canvasPoints.push({ x, y });
      this.setData({ canvasPoints });
      this.redrawCanvas();
    }
  },

  handleCanvasTouchEnd() {
    this.setData({ drawing: false });
    this.convertCanvasToLatLng();
  },

  redrawCanvas() {
    const ctx = wx.createCanvasContext("drawCanvas", this);
    const points = this.data.canvasPoints;
    ctx.setFillStyle("rgba(255, 0, 0, 0.3)");
    ctx.beginPath();
    points.forEach((point, index) => {
      if (index === 0) {
        ctx.moveTo(point.x, point.y);
      } else {
        ctx.lineTo(point.x, point.y);
      }
    });
    ctx.closePath();
    ctx.fill();
    ctx.draw();
  },

2.3 将Canvas坐标转换为经纬度

在触摸结束后,将Canvas上的点坐标转换为地图上的经纬度,并将转换后的点添加到地图的 polygon 中。

  convertCanvasToLatLng() {
    const {
      screenWidth,
      screenHeight,
      longitude,
      latitude,
      mapScale,
    } = this.data;
    const mapCtx = wx.createMapContext("map", this);

    const points = this.data.canvasPoints.map((point) => {
      return new Promise<{ longitude: number; latitude: number }>((resolve) => {
        mapCtx.getScale({
          success: (scaleRes) => {
            const mapScale = scaleRes.scale;
            const numberMapper = new NumberMapper();
            const mappedValue = numberMapper.getMappedValue(mapScale);
            mapCtx.getRegion({
              success(region) {
                const lngDelta =
                  region.northeast.longitude - region.southwest.longitude;
                const latDelta =
                  region.northeast.latitude - region.southwest.latitude;
                const lng =
                  longitude +
                  ((point.x - screenWidth / 2) * lngDelta) / screenWidth;
                const lat =
                  latitude -
                  ((point.y - screenHeight / 2) * latDelta) / screenHeight +
                  mappedValue;
                resolve({ longitude: lng, latitude: lat });
              },
            });
          },
        });
      });
    });

    Promise.all(points).then((latLngPoints) => {
      this.setData({
        polygons: [{ points: latLngPoints }],
      });
    });
  },

2.4 清空画布

在绘制结束并转换坐标后,清空画布以准备下一次绘制。

  convertCanvasToLatLng() {
    const { screenWidth, screenHeight, longitude, latitude } = this.data;
    const mapCtx = wx.createMapContext("map", this);

    const points = this.data.canvasPoints.map((point) => {
      return new Promise<{ longitude: number; latitude: number }>((resolve) => {
        mapCtx.getRegion({
          success(region) {
            const lngDelta =
              region.northeast.longitude - region.southwest.longitude;
            const latDelta =
              region.northeast.latitude - region.southwest.latitude;
            const lng =
              longitude +
              ((point.x - screenWidth / 2) * lngDelta) / screenWidth;
            const lat =
              latitude -
              ((point.y - screenHeight / 2) * latDelta) / screenHeight;
            resolve({ longitude: lng, latitude: lat });
          },
        });
      });
    });

    Promise.all(points).then((latLngPoints) => {
      this.setData({
        polygons: [{ points: latLngPoints }],
        canvasPoints: [] // 清空画布点
      });
    });
  },

3. 减小偏移的解决方案

3.1 基于地图缩放值(scale)的修正

在 convertCanvasToLatLng 方法中,使用 mapCtx.getScale 获取当前地图的缩放值,通过自定义的NumberMapper模块对缩放值进行修正,使得不同缩放级别下的偏移量尽可能减小。

class NumberMapper {
  private hashMap: { [key: number]: number } = {
    3: 12.0,
    3.5: 11.0,
    4: 9.7,
    4.5: 6.5,
    5: 4.8,
    5.5: 3,
    6: 2.33,
    6.5: 1.54,
    7: 1.07,
    7.5: 0.76,
    8: 0.445,
    8.5: 0.375,
    9: 0.265,
    9.5: 0.193,
    10: 0.125,
    10.5: 0.102,
    11: 0.062,
    11.5: 0.048,
    12: 0.0316,
    12.5: 0.0241,
    13: 0.0161,
    13.5: 0.011,
    14: 0.00801,
    14.5: 0.0055,
    15: 0.00401,
    15.5: 0.00271,
    16: 0.00195,
    16.5: 0.00138,
    17: 0.001,
    17.5: 0.0007,
    18: 0.0005,
    18.5: 0.00035,
    19: 0.00024,
    19.5: 0.000171,
    20: 0.000121,
  };

  private processNumber(num: number): number {
    const integerPart = Math.floor(num);
    const decimalPart = num - integerPart;
    if (decimalPart === 0) {
      return integerPart;
    } else if (decimalPart > 0 && decimalPart < 0.5) {
      return integerPart + 0.5;
    } else {
      return integerPart + 1;
    }
  }

  public getMappedValue(num: number): number {
    if (num < 3 || num > 20) {
      throw new Error("Number out of range. Must be between 3 and 20.");
    }
    const ratio = getScreenRatio();
    const processedNumber = this.processNumber(num);
    return this.hashMap[processedNumber];
  }
}

const getScreenRatio = (): number => {
  const systemInfo = wx.getSystemInfoSync();
  const screenWidth = systemInfo.windowWidth;
  const screenHeight = systemInfo.windowHeight;
  const aspectRatio = (screenWidth / screenHeight).toFixed(4);
  return parseFloat(aspectRatio);
};

export default getScreenRatio;

3.2 基于不同机型屏幕宽高比的修正

通过 wx.getSystemInfoSync 获取不同机型的屏幕宽高比,创建一个哈希映射表,将不同宽高比映射到对应的修正值。这样可以减小由于不同设备屏幕比例差异带来的偏移问题。

type RatioMap = { [key: string]: number };
const ratioMap: RatioMap = {
  0.4824: 0.0072,
  // 继续添加其他比例和对应的修正值
};
const getScreenRatio = (): number => {
  const systemInfo = wx.getSystemInfoSync();
  const screenWidth = systemInfo.windowWidth;
  const screenHeight = systemInfo.windowHeight;
  const aspectRatio = (screenWidth / screenHeight).toFixed(4);
  return getMappedValue(parseFloat(aspectRatio));
};
const getMappedValue = (ratio: number): number => {
  return ratioMap[ratio] || 0; // 如果找不到对应值,返回0或其他默认值
};

export default getScreenRatio;

4.完整的代码

确保文件目录结构如下

logs/
├── module/
│   ├── numberMapper.ts
│   ├── screenRatio.ts
├── logs.json
├── logs.scss
├── logs.ts
└── logs.wxml

4.1 wxml

<view class="container">
    <map id="map" class="map" enable-scroll="{{false}}" longitude="{{longitude}}" latitude="{{latitude}}" scale="14" show-location polygons="{{polygons}}">
    </map>
    <canvas style="position:absolute;top:0;left:0;width:100%;height:100%;" canvas-id="drawCanvas" bindtouchstart="handleCanvasTouchStart" bindtouchmove="handleCanvasTouchMove" bindtouchend="handleCanvasTouchEnd"></canvas>
</view>

4.2 scss

.container {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
}
.map {
  width: 100%;
  height: 100vh;
}

4.3 logs.ts

import NumberMapper from "./module/numberMapper";
Page({
  data: {
    longitude: 0,
    latitude: 0,
    polygons: [] as Array<{
      points: Array<{ longitude: number; latitude: number }>;
      strokeWidth: Number;
      strokeColor: string;
      fillColor: string;
    }>,
    drawing: false,
    canvasPoints: [] as Array<{ x: number; y: number }>,
    screenWidth: 0,
    screenHeight: 0,
    mapScale: 14,
  },

  onLoad() {
    const that = this;
    wx.getLocation({
      type: "gcj02",
      success(res) {
        that.setData({
          longitude: res.longitude,
          latitude: res.latitude,
        });
      },
    });

    const systemInfo = wx.getSystemInfoSync();
    this.setData({
      screenWidth: systemInfo.windowWidth,
      screenHeight: systemInfo.windowHeight,
    });

    wx.startLocationUpdate({
      success() {
        wx.onLocationChange((location) => {
          if (!that.data.drawing) {
            const { longitude, latitude } = location;
            that.setData({ longitude, latitude });
          }
        });
      },
    });
  },

  handleCanvasTouchStart() {
    this.setData({ drawing: true, canvasPoints: [] });
  },

  handleCanvasTouchMove(event: any) {
    if (this.data.drawing) {
      const { x, y } = event.touches[0];
      const { canvasPoints } = this.data;
      canvasPoints.push({ x, y });
      this.setData({ canvasPoints });
      this.redrawCanvas();
    }
  },

  handleCanvasTouchEnd() {
    // this.setData({ drawing: false });
    // this.convertCanvasToLatLng();
    this.setData({ drawing: false });
    this.convertCanvasToLatLng().then(() => {
      this.clearCanvas();
    });
  },
  clearCanvas() {
    const ctx = wx.createCanvasContext("drawCanvas", this);
    ctx.clearRect(0, 0, this.data.screenWidth, this.data.screenHeight);
    ctx.draw();
  },

  redrawCanvas() {
    const ctx = wx.createCanvasContext("drawCanvas", this);
    const points = this.data.canvasPoints;
    ctx.setFillStyle("rgba(222, 160, 84, 0.3)");
    ctx.beginPath();
    points.forEach((point, index) => {
      if (index === 0) {
        ctx.moveTo(point.x, point.y);
      } else {
        ctx.lineTo(point.x, point.y);
      }
    });
    ctx.closePath();
    ctx.fill();
    ctx.draw();
  },

  convertCanvasToLatLng() {
    const {
      screenWidth,
      screenHeight,
      longitude,
      latitude,
      mapScale,
    } = this.data;
    const mapCtx = wx.createMapContext("map", this);
    return new Promise<void>((resolve) => {
      const points = this.data.canvasPoints.map((point) => {
        return new Promise<{ longitude: number; latitude: number }>(
          (resolve) => {
            mapCtx.getScale({
              success: (scaleRes) => {
                const mapScale = scaleRes.scale;
                const numberMapper = new NumberMapper();
                const mappedValue = numberMapper.getMappedValue(mapScale);
                console.log(mappedValue, "mappedValue");
                mapCtx.getRegion({
                  success(region) {
                    const lngDelta =
                      region.northeast.longitude - region.southwest.longitude;
                    const latDelta =
                      region.northeast.latitude - region.southwest.latitude;
                    const lng =
                      longitude +
                      ((point.x - screenWidth / 2) * lngDelta) / screenWidth;
                    const lat =
                      latitude -
                      ((point.y - screenHeight / 2) * latDelta) / screenHeight +
                      mappedValue;
                    resolve({ longitude: lng, latitude: lat });
                  },
                });
              },
            });
          }
        );
      });

      Promise.all(points).then((latLngPoints) => {
        this.setData({
          polygons: [
            {
              points: latLngPoints,
              strokeWidth: 3,
              strokeColor: "#dea054",
              fillColor: "#dea0544D",
            },
          ],
        });
        resolve();
      });
    });
  },
});

4.4 module/numberMapper.ts

type HashMap = { [key: number]: number };
import getScreenRatio from "./screenRatio";
class NumberMapper {
  private hashMap: HashMap;

  constructor() {
    this.hashMap = {
      3: 12.0,
      3.5: 11.0,
      4: 9.7,
      4.5: 6.5,
      5: 4.8,
      5.5: 3,
      6: 2.33,
      6.5: 1.54,
      7: 1.07,
      7.5: 0.76,
      8: 0.445,
      8.5: 0.375,
      9: 0.265,
      9.5: 0.193,
      10: 0.125,
      10.5: 0.102,
      11: 0.062,
      11.5: 0.048,
      12: 0.0316,
      12.5: 0.0241,
      13: 0.0161,
      13.5: 0.011,
      14: 0.00801,
      14.5: 0.0055,
      15: 0.00401,
      15.5: 0.00271,
      16: 0.00195,
      16.5: 0.00138,
      17: 0.001,
      17.5: 0.0007,
      18: 0.0005,
      18.5: 0.00035,
      19: 0.00024,
      19.5: 0.000171,
      20: 0.000121,
    };
  }

  private processNumber(num: number): number {
    const integerPart = Math.floor(num);
    const decimalPart = num - integerPart;
    if (decimalPart === 0) {
      return integerPart;
    } else if (decimalPart > 0 && decimalPart < 0.5) {
      return integerPart + 0.5;
    } else {
      return integerPart + 1;
    }
  }

  public getMappedValue(num: number): number {
    if (num < 3 || num > 20) {
      throw new Error("Number out of range. Must be between 3 and 20.");
    }
    const ratio = getScreenRatio(); 
    console.log(ratio, "ratioratio");
    const processedNumber = this.processNumber(num);
    return this.hashMap[processedNumber] - ratio;
  }
}

export default NumberMapper;

4.5 module/screenRatio.ts

type RatioMap = { [key: string]: number };
const ratioMap: RatioMap = {
  0.4824: 0.0072,
  // 添加更多映射值
};
const getScreenRatio = (): number => {
  const systemInfo = wx.getSystemInfoSync();
  const screenWidth = systemInfo.windowWidth;
  const screenHeight = systemInfo.windowHeight;
  const aspectRatio = (screenWidth / screenHeight).toFixed(4);
  return getMappedValue(parseFloat(aspectRatio));
};
const getMappedValue = (ratio: number): number => {
  return ratioMap[ratio] || 0; // 如果找不到对应值,返回0或其他默认值
};

export default getScreenRatio;

例如: module/screenRatio.ts

  1. 搭建 Java 开发环境
  2. 掌握 Java 基本语法
  3. 掌握条件语句
  4. 掌握循环语句

4.6 app.json

"requiredPrivateInfos": ["getLocation", "onLocationChange", "startLocationUpdate"],
"permission": {
        "scope.userLocation": {
            "desc": "你的位置信息将用于小程序位置接口的效果展示"
        }
 },

5.实现原理

•	获取用户位置:使用 wx.getLocation 和 wx.onLocationChange 获取并更新用户当前位置。
•	绘制区域:在地图上覆盖一层透明的 Canvas,用户通过触摸在 Canvas 上绘制区域,记录绘制的点坐标。
•	坐标转换:在触摸结束后,使用 wx.createMapContext 获取当前地图的缩放比例和区域信息,将 Canvas 上的点坐标转换为地图上的经纬度。
•	减少偏移:通过结合地图缩放比例和设备屏幕宽高比,对转换结果进行修正,以减少绘制区域和地图显示区域之间的偏移。
•	画布清空:在坐标转换完成后,清空画布上的点,准备下一次绘制。

6.总结

通过以上实现,成功解决了在微信小程序中用户手动绘制区域并将其显示在地图上的需求。主要步骤包括获取用户位置、绘制区域、进行坐标转换以及减少偏移。这一过程中的关键点在于结合了 map 与 canvas 的特性,实现了用户在地图上绘制区域的功能。

;