TileCutter.js 6.99 KB
/*
 * @Date: 2025-01-22 11:45:30
 * @LastEditors: hookehuyr hookehuyr@gmail.com
 * @LastEditTime: 2025-02-27 09:41:24
 * @FilePath: /map-demo/src/utils/TileCutter.js
 * @Description: 文件描述
 */
import JSZip from "jszip";
import { saveAs } from "file-saver";
import dayjs from "dayjs";

const tileSize = 512;

export function TileCutter(imageURL, bounds, zoomLevel, imageRotation = 0) {
  const img = new Image();
  img.crossOrigin = "Anonymous"; // 避免跨域问题
  img.src = imageURL;
  img.onload = () => {
    sliceImageToTiles(img, bounds, zoomLevel, imageRotation);
  };
}


/**
 * 将图片切割成瓦片并打包下载
 * @param {HTMLImageElement} image - 要切割的图片元素
 * @param {L.LatLngBounds} bounds - 图片边界范围,包含西南角和东北角的经纬度
 * @param {number} zoomLevel - 地图缩放级别
 */
function sliceImageToTiles(image, bounds, zoomLevel, imageRotation) {
  // 创建画布及上下文
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");

  // 获取原始图片尺寸
  const imgWidth = image.width;    // 图片原始宽度
  const imgHeight = image.height;  // 图片原始高度

  // 获取边界经纬度
  const southWest = bounds.getSouthWest();  // 西南角坐标
  const northEast = bounds.getNorthEast();  // 东北角坐标

  // 提取边界经纬度值
  const lonStart = southWest.lng;  // 起始经度
  const latStart = southWest.lat;  // 起始纬度
  const lonEnd = northEast.lng;    // 结束经度
  const latEnd = northEast.lat;    // 结束纬度

  // 计算瓦片坐标范围
  let tileStartX = lonToTileX(lonStart, zoomLevel);  // 起始瓦片X坐标
  let tileEndX = lonToTileX(lonEnd, zoomLevel);      // 结束瓦片X坐标
  let tileStartY = latToTileY(latEnd, zoomLevel);    // 起始瓦片Y坐标
  let tileEndY = latToTileY(latStart, zoomLevel);    // 结束瓦片Y坐标

  // 确保瓦片坐标范围正确(起始值小于结束值)
  tileStartX = Math.min(tileStartX, tileEndX);
  tileEndX = Math.max(tileStartX, tileEndX);
  tileStartY = Math.min(tileStartY, tileEndY);
  tileEndY = Math.max(tileStartY, tileEndY);

  const scaleFactor = 2; // 调高分辨率倍率

  // 设置画布尺寸
  canvas.width = tileSize * scaleFactor;
  canvas.height = tileSize * scaleFactor;

  // 设置画布缩放
  ctx.scale(scaleFactor, scaleFactor);

  const zip = new JSZip();  // 创建一个 JSZip 实例,用来打包所有瓦片

  let tileIndex = 0;  // 瓦片索引,用来给每个瓦片命名

  // 计算每个瓦片的经纬度范围
  for (let tileX = tileStartX; tileX <= tileEndX; tileX++) {
    for (let tileY = tileStartY; tileY <= tileEndY; tileY++) {
      // 计算当前瓦片的经纬度范围
      const tileLonStart = tileX * 360 / Math.pow(2, zoomLevel) - 180;        // 瓦片起始经度
      const tileLonEnd = (tileX + 1) * 360 / Math.pow(2, zoomLevel) - 180;   // 瓦片结束经度
      const tileLatStart = Math.atan(Math.sinh(Math.PI * (1 - 2 * (tileY + 1) / Math.pow(2, zoomLevel)))) * 180 / Math.PI;  // 瓦片起始纬度
      const tileLatEnd = Math.atan(Math.sinh(Math.PI * (1 - 2 * tileY / Math.pow(2, zoomLevel)))) * 180 / Math.PI;          // 瓦片结束纬度

      // 计算图片在当前瓦片中的位置和尺寸(像素坐标)
      const tileImgX = (lonStart - tileLonStart) / (tileLonEnd - tileLonStart) * tileSize;        // 图片在瓦片中的X坐标
      const tileImgY = (tileLatEnd - latEnd) / (tileLatEnd - tileLatStart) * tileSize;           // 图片在瓦片中的Y坐标
      const tileImgWidth = (lonEnd - lonStart) / (tileLonEnd - tileLonStart) * tileSize;         // 图片在瓦片中的宽度
      const tileImgHeight = (latEnd - latStart) / (tileLatEnd - tileLatStart) * tileSize;        // 图片在瓦片中的高度

      // 清空画布
      ctx.clearRect(0, 0, tileSize * scaleFactor, tileSize * scaleFactor);

      // 旋转画布
      ctx.translate(tileSize / 2, tileSize / 2); // 移动原点到画布中心
      ctx.rotate(imageRotation * Math.PI / 180); // 旋转画布
      ctx.translate(-tileSize / 2, -tileSize / 2); // 移回原点

      // 绘制图片到画布
      ctx.drawImage(
        image,
        0, 0, imgWidth, imgHeight, // 源图像区域(使用完整图片)
        tileImgX, tileImgY, tileImgWidth, tileImgHeight // 目标画布区域(保持实际位置和比例)
      );

      // 恢复画布旋转
      ctx.setTransform(1, 0, 0, 1, 0, 0);

      // 将画布内容转换为Blob对象
      canvas.toBlob((blob) => {
        if (!blob) {
          console.error("瓦片转换失败!");
          return;
        }

        // 获取当前北京时间(UTC+8)
        const beijingTime = dayjs().add(8, "hour").toDate();

        // 使用 JSZip 将每个瓦片添加到压缩包中
        zip.file(`${tileX}-${tileY}.png`, blob, { date: beijingTime });
        tileIndex++;

        // 如果所有瓦片都处理完,生成并下载压缩包
        const totalTiles = (tileEndX - tileStartX + 1) * (tileEndY - tileStartY + 1);
        if (tileIndex === totalTiles) {
          generateAndDownloadZip(zip, zoomLevel);
        }
      }, "image/png", 1.0);
    }
  }
}

/**
 * 将经度转换为瓦片X坐标
 * @param {number} lon - 经度值,范围为 -180 到 180
 * @param {number} zoom - 缩放级别
 * @returns {number} - 返回对应的瓦片X坐标
 *
 * 计算过程说明:
 * 1. lon + 180:将经度范围从 [-180, 180] 转换为 [0, 360]
 * 2. /360:将范围归一化到 [0, 1]
 * 3. * Math.pow(2, zoom):根据缩放级别计算实际瓦片坐标
 * 4. Math.floor:向下取整,确保返回整数坐标
 */
function lonToTileX(lon, zoom) {
  return Math.floor(((lon + 180) / 360) * Math.pow(2, zoom));
}

/**
 * 将纬度转换为瓦片Y坐标
 * @param {number} lat - 纬度值,范围为 -90 到 90
 * @param {number} zoom - 缩放级别
 * @returns {number} - 返回对应的瓦片Y坐标
 *
 * 计算过程说明:
 * 1. lat * Math.PI / 180:将纬度从角度转换为弧度
 * 2. Math.tan() + 1/Math.cos():计算墨卡托投影中的y值
 * 3. Math.log():取自然对数
 * 4. 1 - ... / Math.PI:将范围调整到 [0, 1]
 * 5. / 2:将范围进一步归一化
 * 6. * Math.pow(2, zoom):根据缩放级别计算实际瓦片坐标
 * 7. Math.floor:向下取整,确保返回整数坐标
 */
function latToTileY(lat, zoom) {
  // 将纬度转换为墨卡托投影坐标,然后计算对应的瓦片Y坐标
  return Math.floor(
    ((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2) * Math.pow(2, zoom)
  );
}

function saveTile(blob, filename) {
  const link = document.createElement("a");
  link.href = URL.createObjectURL(blob);
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
}

// 生成并下载压缩包
function generateAndDownloadZip(zip, zoomLevel) {
  zip.generateAsync({ type: "blob" }).then((content) => {
    // 使用 FileSaver.js 下载压缩包
    saveAs(content, `${zoomLevel}级瓦片切片包.zip`);
  });
}