基于 Turf.js 实现高精度多边形修整工具(模拟 ArcGIS 修整功能)

如果需要详细的地图应用步骤可参考博主的另一篇分割文章https://blog.csdn.net/qq_47285211/article/details/159544070?spm=1011.2124.3001.6209

在 GIS 前端开发、国土空间规划、图斑编辑等场景中,多边形修整是核心刚需功能 ------ 类似 ArcGIS 的修整工具,通过一条线切割多边形,自动保留面积更大的有效图斑,剔除细碎小面。

原生 MapboxGL 没有直接提供高精度、可控的多边形修整 API,而简单调用 Turf.js 的differenceunion会出现坐标丢失、自相交、空洞错乱、碎面残留等问题,无法满足生产环境要求。

本文基于Turf.js 封装了一套高精度、兼容生产、逻辑对标 ArcGIS的多边形修整类,支持:

  • 完全保留原始坐标,不简化、不偏移
  • 正确处理多边形空洞(内环)归属
  • 自动识别有效交点,拒绝无效切割
  • 切割后自动保留面积大的图斑

一、功能核心亮点

  1. 高精度几何处理 :1:1 复刻分割工具坐标逻辑,容差1e-8统一对齐
  2. 仿 ArcGIS 修整逻辑:画线切割 → 生成两面 → 保留大面
  3. 空洞智能分配:按质心归属 + 距离判断,保证空洞不丢失、不错位
  4. 健壮异常处理:过滤无效交点、重复点、极小面、自相交
  5. 生产可用:返回标准 GeoJSON,可直接用于 Mapbox 渲染 / 后端入库
  6. 无侵入、纯前端:仅依赖 Turf.js,可直接集成 Vue/React/ 原生项目

二、适用场景

  • 征地范围边界调整
  • 规划图形裁剪优化
  • 前端 GIS 图形编辑工具
  • 与 MapboxGL Draw 结合实现交互式图斑编辑

三、完整实现代码(可直接复制使用)

javascript 复制代码
import * as turf from '@turf/turf';

class PolygonTrimmer {
  constructor() {
    this.turf = turf;
    this.EPS = 1e-8;        // 坐标精度容差(和分割工具一致)
    this.tolerance = 1e-10; // 浮点判断精度
  }

  /**
   * 核心方法:修整多边形(模拟ArcGIS修整工具)- 生成两个候选面后保留面积大的
   * @param {Object} polygonGeoJSON - 原始多边形 GeoJSON (Polygon/MultiPolygon)
   * @param {Object} lineGeoJSON - 修整线 GeoJSON (LineString)
   * @returns {Object|null} 修整后的多边形 GeoJSON(面积大的那个),失败返回null
   */
  trimPolygon(polygonGeoJSON, lineGeoJSON) {
    try {
      // 1. 基础校验(和分割工具保持一致)
      if (!polygonGeoJSON) throw new Error('未传入目标多边形');
      if (!lineGeoJSON) throw new Error('未传入修整线');

      // 统一转为单Polygon处理
      const polygonFeature = this._normalizeToPolygon(polygonGeoJSON);
      const lineFeature = this.turf.feature(lineGeoJSON.geometry);

      if (lineFeature.geometry.type !== 'LineString') {
        console.error('仅支持LineString类型的修整线');
        return null;
      }

      // 2. 提取多边形外环和空洞
      const originalOuterRing = polygonFeature.geometry.coordinates[0];
      const originalHoles = polygonFeature.geometry.coordinates.slice(1);
      const polygonBoundary = this.turf.lineString(originalOuterRing); // 转为边界线

      // 3. 查找修整线与多边形边界的有效交点对(参考分割工具逻辑)
      const validIntersections = this._findValidIntersectionPair(polygonBoundary, lineFeature);
      if (!validIntersections || validIntersections.length < 2) {
        console.warn('修整线必须与多边形边界相交两次');
        return null;
      }
      const [p1, p2] = validIntersections;
      console.log('有效交点对:', p1, p2);
      console.log('交点距离:', this.turf.distance(this.turf.point(p1), this.turf.point(p2)));
      console.log('交点1是否在多边形边界:', this._isPointOnSegment(p1, originalOuterRing[0], originalOuterRing[1]));

      // 4. 手动切割外环(100%保留原始坐标,参考分割工具_manualSplitRing)
      const [clippedRing1, clippedRing2] = this._manualSplitRing(originalOuterRing, p1, p2);

      // 5. 手动切割修整线(仅保留交点间的线段)
      const clippedTrimLineCoords = this._manualSplitLine(lineFeature.geometry.coordinates, p1, p2);

      // 6. 拼接两个候选面的外环(参考分割工具_connectLine)
      const poly1Outer = this._connectRingAndLine(clippedRing1, clippedTrimLineCoords);
      const poly2Outer = this._connectRingAndLine(clippedRing2, clippedTrimLineCoords);

      // 7. 处理空洞归属(参考分割工具的空洞逻辑)
      const { poly1Holes, poly2Holes } = this._assignHolesToPolygons(originalHoles, poly1Outer, poly2Outer);

      // 8. 构建两个完整的候选多边形
      const candidateA = this.turf.polygon([poly1Outer, ...poly1Holes], polygonFeature.properties);
      const candidateB = this.turf.polygon([poly2Outer, ...poly2Holes], polygonFeature.properties);

      // 9. 清理坐标(修复自相交,保留原始精度)
      const cleanedA = this._cleanPolygon(candidateA);
      const cleanedB = this._cleanPolygon(candidateB);

      // 10. 面积对比:保留大面、移除小面
      const areaA = this.turf.area(cleanedA);
      const areaB = this.turf.area(cleanedB);
      const minValidArea = 0.0001; // 极小面过滤阈值

      // 校验有效面积
      if (areaA < minValidArea && areaB < minValidArea) {
        console.error('两个候选面面积均过小,修整失败');
        return null;
      }

      // 选择面积大的面作为最终结果
      let finalResult;
      if (areaA >= areaB) {
        finalResult = cleanedA;
        console.log(`保留候选面A(面积:${areaA.toFixed(6)}),移除候选面B(面积:${areaB.toFixed(6)})`);
      } else {
        finalResult = cleanedB;
        console.log(`保留候选面B(面积:${areaB.toFixed(6)}),移除候选面A(面积:${areaA.toFixed(6)})`);
      }

      // 补充属性(参考分割工具的属性规则)
      finalResult.properties = {
        ...polygonFeature.properties,
        trim: true,
        trimTime: Date.now(),
        trimId: `${polygonFeature.properties.gid || 'poly'}_trim_0`,
        candidateAreaA: areaA,
        candidateAreaB: areaB,
        selectedByArea: true,
        selectedCandidate: areaA >= areaB ? 'A' : 'B',
        innerRingsCount: finalResult.geometry.coordinates.length - 1,
        originalInnerRingsCount: originalHoles.length
      };
      const originalArea = this.turf.area(polygonFeature);
const finalArea = this.turf.area(finalResult);
console.log('原始面积:', originalArea.toFixed(6));
console.log('修整后面积:', finalArea.toFixed(6));

// 检查几何是否一致
const isSameGeometry = JSON.stringify(finalResult.geometry.coordinates) === JSON.stringify(polygonFeature.geometry.coordinates);
if (isSameGeometry) {
  console.warn('修整后几何与原始几何一致,可能是修整线无效');
}

      return finalResult;
    } catch (error) {
      console.error('修整多边形失败:', error);
      return null;
    }
  }

  /**
   * 归一化为单Polygon(处理MultiPolygon)
   * @private
   */
  _normalizeToPolygon(geoJSON) {
    const feature = this.turf.feature(geoJSON.geometry);
    if (feature.geometry.type === 'MultiPolygon') {
      // 取第一个Polygon(和分割工具逻辑一致)
      return this.turf.polygon(feature.geometry.coordinates[0], feature.properties);
    } else if (feature.geometry.type === 'Polygon') {
      return feature;
    } else {
      throw new Error(`仅支持Polygon/MultiPolygon类型,当前为${feature.geometry.type}`);
    }
  }

  /**
   * 查找修整线与多边形边界的有效交点对(参考分割工具_setFloors)
   * @private
   */
  _findValidIntersectionPair(boundaryLine, trimLine) {
     const intersections = this.turf.lineIntersect(boundaryLine, trimLine).features;
  if (intersections.length < 2) return null;

  // 1. 过滤无效交点(必须在边界线段上,非顶点)
  const validPoints = [];
  for (const p of intersections) {
    const pt = p.geometry.coordinates;
    let isOnSegment = false;
    // 检查是否在边界线段上(非顶点)
    for (let i = 0; i < boundaryLine.geometry.coordinates.length - 1; i++) {
      const a = boundaryLine.geometry.coordinates[i];
      const b = boundaryLine.geometry.coordinates[i + 1];
      if (this._isPointOnSegment(pt, a, b) && 
          this.turf.distance(this.turf.point(pt), this.turf.point(a)) > this.EPS &&
          this.turf.distance(this.turf.point(pt), this.turf.point(b)) > this.EPS) {
        isOnSegment = true;
        break;
      }
    }
    if (isOnSegment) validPoints.push(pt);
  }

  if (validPoints.length < 2) return null;

  // 2. 去重(距离 > EPS)
  const uniquePoints = [];
  for (const pt of validPoints) {
    const isDuplicate = uniquePoints.some(u => 
      this.turf.distance(this.turf.point(u), this.turf.point(pt)) < this.EPS
    );
    if (!isDuplicate) uniquePoints.push(pt);
  }

  if (uniquePoints.length < 2) return null;

  // 3. 按修整线方向排序
  uniquePoints.sort((a, b) => {
    const distA = this.turf.nearestPointOnLine(trimLine, this.turf.point(a)).properties.location;
    const distB = this.turf.nearestPointOnLine(trimLine, this.turf.point(b)).properties.location;
    return distA - distB;
  });

  return uniquePoints.slice(0, 2);
  }

  /**
   * 手动切割外环(100%保留原始坐标,参考分割工具_manualSplitRing)
   * @private
   */
  _manualSplitRing(ringCoords, p1, p2) {
    // 移除闭合点,保留原始坐标
  const ring = [...ringCoords.slice(0, -1)];
  
  // 修复:遍历到倒数第一个点(i < ring.length - 1)
  const insertPoint = (pt) => {
    // 先检查是否已存在该点
    for (let i = 0; i < ring.length; i++) {
      if (this.turf.distance(this.turf.point(ring[i]), this.turf.point(pt)) < this.EPS) {
        return; // 已存在,无需插入
      }
    }
    // 插入到线段中间
    for (let i = 0; i < ring.length - 1; i++) {
      const a = ring[i], b = ring[i + 1];
      if (this._isPointOnSegment(pt, a, b)) {
        ring.splice(i + 1, 0, pt);
        return;
      }
    }
    // 无有效线段,返回null(切割失败)
    throw new Error(`交点 ${pt} 不在多边形外环线段上`);
  };

  try {
    insertPoint(p1);
    insertPoint(p2);
  } catch (e) {
    console.error('插入交点失败:', e);
    return [ringCoords, ringCoords]; // 返回原环,避免报错
  }

  // 查找交点索引
  const idx1 = this._findPointIndex(ring, p1);
  const idx2 = this._findPointIndex(ring, p2);

  if (idx1 === -1 || idx2 === -1 || idx1 === idx2) {
    console.warn('交点索引无效,切割失败');
    return [ringCoords, ringCoords];
  }

  // 切割为两段
  let part1, part2;
  if (idx1 < idx2) {
    part1 = ring.slice(idx1, idx2 + 1);
    part2 = ring.slice(idx2).concat(ring.slice(0, idx1 + 1));
  } else {
    part1 = ring.slice(idx2, idx1 + 1);
    part2 = ring.slice(idx1).concat(ring.slice(0, idx2 + 1));
  }

  // 闭合环(确保首尾一致)
  part1 = [...part1, part1[0]];
  part2 = [...part2, part2[0]];

  // 验证切割结果
  console.log('切割后环1长度:', part1.length);
  console.log('切割后环2长度:', part2.length);
  return [part1, part2];
  }

  /**
   * 手动切割修整线(参考分割工具_manualSplitLine)
   * @private
   */
  _manualSplitLine(lineCoords, p1, p2) {
    const line = [...lineCoords];

    // 插入交点到线中
    const insertPoint = (pt) => {
      for (let i = 0; i < line.length; i++) {
        const a = line[i], b = line[i + 1];
        if (this._isPointOnSegment(pt, a, b)) {
          line.splice(i + 1, 0, pt);
          return;
        }
      }
      line.push(pt);
    };
    insertPoint(p1);
    insertPoint(p2);

    // 查找交点索引并切割
    const idx1 = this._findPointIndex(line, p1);
    const idx2 = this._findPointIndex(line, p2);
    return idx1 < idx2 ? line.slice(idx1, idx2 + 1) : line.slice(idx2, idx1 + 1);
  }

  /**
   * 拼接环和修整线(参考分割工具_connectLine)
   * @private
   */
  _connectRingAndLine(ring, lineCoords) {
    if (lineCoords.length < 2) return ring; // 空修整线,返回原环

  const ringEnd = ring[ring.length - 2]; // 倒数第二个点(排除闭合点)
  const lineStart = lineCoords[0];
  const lineEnd = lineCoords[lineCoords.length - 1];

  // 修复:增加距离阈值,避免精度误差
  const distToStart = this.turf.distance(this.turf.point(ringEnd), this.turf.point(lineStart));
  const distToEnd = this.turf.distance(this.turf.point(ringEnd), this.turf.point(lineEnd));

  let mergedLine;
  if (distToStart < this.EPS * 10) {
    mergedLine = [...ring.slice(0, -1), ...lineCoords.slice(1)];
  } else if (distToEnd < this.EPS * 10) {
    mergedLine = [...ring.slice(0, -1), ...lineCoords.reverse().slice(1)];
  } else {
    // 无匹配端点,直接拼接(避免返回原环)
    mergedLine = [...ring.slice(0, -1), ...lineCoords];
  }

  // 闭合环并去重
  mergedLine = [...new Set(mergedLine.map(pt => pt.join(',')))].map(str => str.split(',').map(Number));
  mergedLine.push(mergedLine[0]);

  // 验证拼接结果
  console.log('拼接前环长度:', ring.length);
  console.log('拼接后环长度:', mergedLine.length);
  return mergedLine;
  }

  /**
   * 空洞归属判断(参考分割工具_singleClip)
   * @private
   */
  _assignHolesToPolygons(holes, poly1Outer, poly2Outer) {
    const poly1Holes = [], poly2Holes = [];
    const tempPoly1 = this.turf.polygon([poly1Outer]);
    const tempPoly2 = this.turf.polygon([poly2Outer]);

    for (const hole of holes) {
      const center = this.turf.centroid(this.turf.polygon([hole]));
      const in1 = this.turf.booleanPointInPolygon(center, tempPoly1);
      const in2 = this.turf.booleanPointInPolygon(center, tempPoly2);

      if (in1 && !in2) {
        poly1Holes.push(hole);
      } else if (in2 && !in1) {
        poly2Holes.push(hole);
      } else if (in1 && in2) {
        // 同时在两个面内时,按距离交点的远近分配
        const p1 = this.turf.point(poly1Outer[0]);
        const d1 = this.turf.distance(center, p1);
        const d2 = this.turf.distance(center, this.turf.point(poly2Outer[0]));
        d1 < d2 ? poly1Holes.push(hole) : poly2Holes.push(hole);
      }
    }

    return { poly1Holes, poly2Holes };
  }

  /**
   * 清理多边形(修复自相交,保留原始坐标)
   * @private
   */
  _cleanPolygon(poly) {
    let cleaned = this.turf.cleanCoords(poly);
    // 修复自相交(不简化坐标)
    const kinks = this.turf.kinks(cleaned);
    if (kinks.features.length > 0) {
      cleaned = this.turf.polygon(
        cleaned.geometry.coordinates,
        cleaned.properties
      );
    }
    return cleaned;
  }

  /**
   * 查找点在坐标数组中的索引(参考分割工具_findPointIndex)
   * @private
   */
  _findPointIndex(coords, pt) {
    for (let i = 0; i < coords.length; i++) {
      if (this.turf.distance(this.turf.point(coords[i]), this.turf.point(pt)) < this.EPS) {
        return i;
      }
    }
    return -1;
  }

  /**
   * 判断点是否在线段上(参考分割工具_isPointOnSegment)
   * @private
   */
  _isPointOnSegment(pt, a, b) {
    const cross = (pt[0] - a[0]) * (b[1] - a[1]) - (pt[1] - a[1]) * (b[0] - a[0]);
    if (Math.abs(cross) > this.EPS) return false;

    const minX = Math.min(a[0], b[0]), maxX = Math.max(a[0], b[0]);
    const minY = Math.min(a[1], b[1]), maxY = Math.max(a[1], b[1]);

    return pt[0] >= minX - this.EPS && pt[0] <= maxX + this.EPS &&
      pt[1] >= minY - this.EPS && pt[1] <= maxY + this.EPS;
  }

  /**
   * 移除小面积碎块(保留空洞,兼容原有方法)
   * @public
   */
  removeSmallFragments(polygonGeoJSON, minArea) {
    const multiPolygon = this.turf.geomType(polygonGeoJSON) === 'Polygon'
      ? this.turf.multiPolygon([polygonGeoJSON.geometry.coordinates])
      : polygonGeoJSON;

    const validPolygons = [];
    multiPolygon.geometry.coordinates.forEach(coords => {
      const poly = this.turf.polygon(coords);
      const area = this.turf.area(poly);
      if (area >= minArea) {
        validPolygons.push(coords);
      }
    });

    const result = this.turf.multiPolygon(validPolygons);
    result.properties = {
      ...polygonGeoJSON.properties,
      cleaned: true,
      cleanTime: Date.now(),
      innerRingsPreserved: true
    };
    return validPolygons.length ? result : polygonGeoJSON;
  }
}

// ========== 导出风格对齐分割工具 ==========
const polygonTrimmer = new PolygonTrimmer();

/**
 * 对外暴露的整形方法(和splitPolygon写法一致)
 * @param {Object} targetPolygon - 目标多边形GeoJSON
 * @param {Object} trimLine - 整形线GeoJSON
 * @returns {Object|null} 整形结果(面积大的那个面)
 */
export function trimPolygon(targetPolygon, trimLine) {
  try {
    const trimResult = polygonTrimmer.trimPolygon(targetPolygon, trimLine);
    // 统一返回数组格式(和splitPolygon的返回值对齐)
    return trimResult ? [trimResult] : null;
  } catch (error) {
    console.error('多边形整形出错:', error);
    return null;
  }
}

/**
 * 对外暴露的移除小碎块方法
 * @param {Object} targetPolygon - 目标多边形GeoJSON
 * @param {number} minArea - 最小保留面积
 * @returns {Object|null} 清理结果
 */
export function removeSmallFragments(targetPolygon, minArea) {
  try {
    return polygonTrimmer.removeSmallFragments(targetPolygon, minArea);
  } catch (error) {
    console.error('移除小碎块出错:', error);
    return null;
  }
}

/**
 * 创建整形线(和createSplitLine写法一致)
 * @param {Array} start - 起点 [x,y]
 * @param {Array} end - 终点 [x,y]
 * @returns {Object|null} 整形线GeoJSON
 */
export function createTrimLine(start, end) {
  if (!start || !end || start.length !== 2 || end.length !== 2) return null;
  return turf.lineString([start, end], { name: 'trim-line' });
}

四、使用示例

javascript 复制代码
import { trimPolygon, createTrimLine, removeSmallFragments } from './PolygonTrimmer'

// 1. 准备原始多边形(标准GeoJSON)
const polygon = {
  type: 'Feature',
  properties: {},
  geometry: {
    type: 'Polygon',
    coordinates: [[
      [116.1, 39.1],
      [116.2, 39.1],
      [116.2, 39.2],
      [116.1, 39.2],
      [116.1, 39.1]
    ]]
  }
}

// 2. 创建修整线
const trimLine = createTrimLine([116.12, 39.12], [116.18, 39.18])

// 3. 执行修整
const result = trimPolygon(polygon, trimLine)

// 4. 输出结果(可直接用于Mapbox渲染)
console.log('修整完成', result)

五、效果图

相关推荐
踩着两条虫2 小时前
VTJ.PRO 在线应用开发平台的工作台与后台管理视图
前端·人工智能·ai编程
charlie1145141912 小时前
通用GUI编程技术——Win32 原生编程实战(十八)——GDI 设备上下文(HDC)完全指南
开发语言·c++·ide·学习·visual studio·win32
踩着两条虫2 小时前
VTJ.PRO 在线应用开发平台多平台运行时(Web, H5, UniApp)
前端·低代码·ai编程
Mr YiRan2 小时前
JNI技术之动态注册与JNI线程实战
开发语言
庄小法2 小时前
pytest
开发语言·python·pytest
sonnet-10292 小时前
堆排序算法
java·c语言·开发语言·数据结构·python·算法·排序算法
csdn_zhangchunfeng2 小时前
Qt之智能指针使用建议
开发语言·qt
ZC1995922 小时前
Node.js npm 安装过程中 EBUSY 错误的分析与解决方案
前端·npm·node.js
升鲜宝供应链及收银系统源代码服务2 小时前
生鲜配送供应链管理系统源代码之升鲜宝社区团购商城小程序(一)
java·前端·数据库·小程序·notepad++·供应链系统源代码·多门店收银系统