金亚洲技术笔记

凡是过往,皆为序章。

射线法实现地理电子围栏

Posted on   » 物联网iot • 4421 words • 9 minute read

本文文字及代码均使用AI生成。

地图电子围栏实现方案

下面是一个基于 HTML、JavaScript 和 Canvas 的电子围栏模拟系统,实现了多边形围栏绘制和坐标点判断功能。系统使用射线法 (Ray Casting Algorithm) 来判断点是否在多边形内部,这是一种高效且常用的地理空间判断算法,效果图如下:

ScreenShot_2025-10-23_103931_265


在线演示:[ 点击这里 ]

1. 射线法(Ray Casting Algorithm)的原理

射线法是判断点是否在多边形内部的经典算法,其核心原理如下:

  1. 发射射线:从目标点向右(或任意固定方向)发射一条水平射线。
  2. 统计交点:计算该射线与多边形所有边的交点数量。
  3. 判断规则
    • 若交点数量为奇数,则点在多边形内部
    • 若交点数量为偶数(包括 0),则点在多边形外部

核心逻辑:射线每穿过多边形的一条边,就会从 “内部” 和 “外部” 状态切换一次,最终状态由切换次数的奇偶性决定。该算法对简单多边形(非自相交)和复杂多边形均适用,且实现简单、效率较高。

2. 其它判断点是否在多边形内部的方法

除射线法外,常见的方法还有:

(1)转角法(Winding Number Algorithm)

  • 原理:计算从目标点到多边形所有顶点的连线与正 x 轴的夹角变化总和(即 “绕数”)。
  • 判断规则
    • 若绕数为0,点在多边形外部;
    • 若绕数为非 0,点在多边形内部。
  • 特点:能区分点在多边形内部还是外部,且对自相交多边形也能处理,但计算量略大于射线法。

(2)叉积法(适用于凸多边形)

  • 原理:对于凸多边形,判断点是否在所有边的 “内侧”(通过叉积判断方向一致性)。
  • 判断规则:若点在多边形所有边的同一侧(如左侧),则在内部;否则在外部。
  • 特点:仅适用于凸多边形,计算速度快(时间复杂度 O (n),n 为边数)。

(3)扫描线算法

  • 原理:通过扫描线与多边形边的交点,记录 “进入” 和 “退出” 状态,判断点所在区间的状态。
  • 特点:适合批量点判断,常与空间索引结合优化性能。

3. 电子围栏的性能优化方法

电子围栏的性能优化需结合场景(如围栏数量、点数量、实时性要求),常见手段如下:

(1)空间索引优化

  • 原理:通过预处理将围栏或点划分到不同的空间区域,减少判断时的计算量。
  • 常用索引
    • 网格索引:将地图划分为固定大小的网格,每个网格关联包含的围栏,判断点时仅需检查所在网格的围栏。
    • R 树 / R + 树:适用于不规则分布的围栏,高效检索与目标点可能相交的围栏。

(2)围栏预处理

  • 简化多边形:对顶点过多的围栏,使用道格拉斯 - 普克算法(Douglas-Peucker)简化顶点,减少判断时的边数量。
  • 合并围栏:对重叠或相邻的围栏进行合并,减少整体围栏数量。

(3)算法优化

  • 提前过滤:先通过围栏的外接矩形(Bounding Box)快速判断点是否可能在围栏内,若不在则直接排除(无需执行完整的射线法 / 转角法)。
  • 批量处理:对多个点同时判断时,采用并行计算或向量运算加速。

(4)缓存与更新策略

  • 缓存结果:对频繁查询的固定点,缓存其是否在围栏内的结果,避免重复计算。
  • 增量更新:围栏或点集变化时,仅重新计算受影响的部分,而非全量重算。

(5)硬件与工程优化

  • Web 场景:使用 WebWorker 处理密集计算,避免阻塞主线程;对大规模数据,考虑使用 WebGL 加速图形渲染和判断。
  • 后端场景:采用 C++ 等高性能语言实现核心算法,或使用 GPU 加速并行计算。

这些方法可根据实际需求组合使用,例如:先用外接矩形快速过滤,再用射线法精确判断,同时结合网格索引减少待检查的围栏数量,大幅提升电子围栏系统的响应速度。


源码如下:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>电子围栏模拟系统(优化版)</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
            background-color: #f0f0f0;
        }
        .container {
            display: flex;
            gap: 20px;
        }
        #mapCanvas {
            border: 2px solid #333;
            background-color: white;
            cursor: crosshair;
        }
        .controls {
            background-color: white;
            padding: 15px;
            border-radius: 5px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
            width: 300px;
        }
        .control-group {
            margin-bottom: 15px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            font-weight: bold;
        }
        input, button {
            width: 100%;
            padding: 8px;
            margin-bottom: 10px;
            box-sizing: border-box;
        }
        button {
            background-color: #4CAF50;
            color: white;
            border: none;
            cursor: pointer;
        }
        button:hover {
            background-color: #45a049;
        }
        button:disabled {
            background-color: #cccccc;
            cursor: not-allowed;
        }
        #result {
            margin-top: 15px;
            padding: 10px;
            border-radius: 3px;
        }
        .inside {
            background-color: #dff0d8;
            color: #3c763d;
        }
        .outside {
            background-color: #f2dede;
            color: #a94442;
        }
        .fence-list {
            max-height: 150px;
            overflow-y: auto;
            border: 1px solid #ddd;
            padding: 5px;
            margin-bottom: 10px;
        }
        .fence-item {
            padding: 5px;
            margin-bottom: 5px;
            background-color: #f9f9f9;
            border-radius: 3px;
        }
        .magnet-hint {
            position: absolute;
            background-color: rgba(255, 152, 0, 0.8);
            color: white;
            padding: 3px 8px;
            border-radius: 3px;
            font-size: 12px;
            pointer-events: none;
            display: none;
        }
    </style>
</head>
<body>
    <h1>电子围栏模拟系统(优化版)</h1>
    <div class="container">
        <div style="position: relative;">
            <canvas id="mapCanvas" width="800" height="600"></canvas>
            <div class="magnet-hint" id="magnetHint">吸附闭合</div>
        </div>
        <div class="controls">
            <div class="control-group">
                <label>围栏操作</label>
                <button id="startFence">开始绘制围栏</button>
                <button id="finishFence" disabled>完成当前围栏</button>
                <button id="clearAll">清除所有围栏</button>
            </div>
            
            <div class="control-group">
                <label>围栏列表</label>
                <div id="fenceList" class="fence-list"></div>
            </div>
            
            <div class="control-group">
                <label>坐标检查</label>
                <input type="number" id="xCoord" placeholder="X坐标" min="0" max="800">
                <input type="number" id="yCoord" placeholder="Y坐标" min="0" max="600">
                <button id="checkPoint">检查坐标</button>
            </div>
            
            <div id="result" class="outside">
                检查结果将显示在这里
            </div>
        </div>
    </div>

    <script>
        // 获取画布和上下文
        const canvas = document.getElementById('mapCanvas');
        const ctx = canvas.getContext('2d');
        const magnetHint = document.getElementById('magnetHint');
        
        // 围栏数据
        let fences = [];          // 所有围栏
        let currentFence = [];    // 当前正在绘制的围栏
        let isDrawing = false;    // 是否正在绘制
        const MAGNET_DISTANCE = 20; // 磁吸生效距离(像素)
        
        // DOM元素
        const startFenceBtn = document.getElementById('startFence');
        const finishFenceBtn = document.getElementById('finishFence');
        const clearAllBtn = document.getElementById('clearAll');
        const checkPointBtn = document.getElementById('checkPoint');
        const xCoordInput = document.getElementById('xCoord');
        const yCoordInput = document.getElementById('yCoord');
        const resultDiv = document.getElementById('result');
        const fenceListDiv = document.getElementById('fenceList');
        
        // 初始化画布
        function initCanvas() {
            ctx.fillStyle = '#e8f4f8';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            
            // 绘制网格背景
            drawGrid();
        }
        
        // 绘制网格
        function drawGrid() {
            ctx.strokeStyle = '#d0e8f0';
            ctx.lineWidth = 1;
            
            // 绘制水平线
            for (let y = 50; y < canvas.height; y += 50) {
                ctx.beginPath();
                ctx.moveTo(0, y);
                ctx.lineTo(canvas.width, y);
                ctx.stroke();
                
                // 绘制坐标
                ctx.fillStyle = '#666';
                ctx.font = '10px Arial';
                ctx.fillText(y, 5, y + 3);
            }
            
            // 绘制垂直线
            for (let x = 50; x < canvas.width; x += 50) {
                ctx.beginPath();
                ctx.moveTo(x, 0);
                ctx.lineTo(x, canvas.height);
                ctx.stroke();
                
                // 绘制坐标
                ctx.fillStyle = '#666';
                ctx.font = '10px Arial';
                ctx.fillText(x, x - 5, 15);
            }
        }
        
        // 计算两点之间的距离
        function getDistance(p1, p2) {
            const dx = p1.x - p2.x;
            const dy = p1.y - p2.y;
            return Math.sqrt(dx * dx + dy * dy);
        }
        
        // 射线法判断点是否在多边形内部
        function isPointInPolygon(point, polygon) {
            const x = point.x;  // 目标点的X坐标
            const y = point.y;  // 目标点的Y坐标
            let inside = false; // 标记点是否在多边形内部,初始为外部

            // 遍历多边形的每条边:
            // i是当前顶点索引,j是前一个顶点索引(形成边 j->i)
            // 循环结束后j会自动更新为i,i递增,实现边的依次遍历
            for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
                // 获取当前边的两个顶点坐标
                const xi = polygon[i].x, yi = polygon[i].y; // 当前顶点(i)的坐标
                const xj = polygon[j].x, yj = polygon[j].y; // 前一个顶点(j)的坐标

                // 核心判断逻辑:检查射线是否与当前边相交
                // 分为两部分:垂直线范围检查 + 水平射线交点检查

                // 1. 垂直线范围检查:((yi > y) !== (yj > y))
                // 作用:判断点的Y坐标是否在当前边的Y坐标区间内
                // 原理:
                // - 如果边的两个端点(yi和yj)分别在点y坐标的两侧(一个大于y,一个小于y)
                // - 则说明点的水平射线(y固定)可能与这条边相交
                // - 若两点都在y上方或都在y下方,则射线不可能与边相交,直接排除
                const isYRangeValid = (yi > y) !== (yj > y);

                // 2. 水平射线交点检查:x < (xj - xi) * (y - yi) / (yj - yi) + xi
                // 作用:计算射线与边的交点X坐标,并判断是否在点的右侧
                // 原理:
                // - 公式是通过直线方程推导的:求边所在直线与y=point.y的交点X坐标
                // - 若交点X坐标大于点的X坐标,说明射线(向右延伸)会穿过这条边
                const intersectionX = (xj - xi) * (y - yi) / (yj - yi) + xi;
                const isIntersectionRight = x < intersectionX;

                // 当两个条件同时满足时,射线与当前边相交
                const isIntersect = isYRangeValid && isIntersectionRight;

                // 每相交一次,内外状态翻转一次
                if (isIntersect) {
                    inside = !inside;
                }
            }

            // 最终返回点是否在内部
            return inside;
        }
        
        // 检查点是否在任何围栏内
        function checkPointInAnyFence(x, y) {
            const point = {x, y};
            
            for (let i = 0; i < fences.length; i++) {
                if (isPointInPolygon(point, fences[i])) {
                    return { inside: true, fenceIndex: i };
                }
            }
            
            return { inside: false };
        }
        
        // 绘制所有围栏
        function drawFences() {
            fences.forEach((fence, index) => {
                drawPolygon(fence, index === fences.length - 1 ? '#4CAF50' : '#2196F3');
            });
            
            // 绘制当前正在绘制的围栏
            if (currentFence.length > 0) {
                drawCurrentFence();
            }
        }
        
        // 专门绘制当前正在编辑的围栏
        function drawCurrentFence() {
            // 绘制所有已有点
            currentFence.forEach(point => {
                drawVertex(point.x, point.y);
            });
            
            // 如果有多个点,绘制连接线
            if (currentFence.length > 1) {
                ctx.beginPath();
                ctx.moveTo(currentFence[0].x, currentFence[0].y);
                
                for (let i = 1; i < currentFence.length; i++) {
                    ctx.lineTo(currentFence[i].x, currentFence[i].y);
                }
                
                // 绘制边框
                ctx.strokeStyle = '#FF9800';
                ctx.lineWidth = 2;
                ctx.stroke();
            }
            
            // 绘制磁吸范围指示器(如果满足条件)
            if (currentFence.length > 1) {
                const firstPoint = currentFence[0];
                const lastPoint = currentFence[currentFence.length - 1];
                
                // 如果最后一个点靠近第一个点,绘制磁吸范围
                if (getDistance(lastPoint, firstPoint) < MAGNET_DISTANCE) {
                    ctx.strokeStyle = 'rgba(255, 152, 0, 0.5)';
                    ctx.lineWidth = 2;
                    ctx.setLineDash([5, 3]);
                    ctx.beginPath();
                    ctx.arc(firstPoint.x, firstPoint.y, MAGNET_DISTANCE, 0, Math.PI * 2);
                    ctx.stroke();
                    ctx.setLineDash([]);
                }
            }
        }
        
        // 绘制多边形(已完成的围栏)
        function drawPolygon(points, color, fill = true) {
            if (points.length < 2) return;
            
            ctx.beginPath();
            ctx.moveTo(points[0].x, points[0].y);
            
            for (let i = 1; i < points.length; i++) {
                ctx.lineTo(points[i].x, points[i].y);
            }
            
            // 闭合多边形
            if (fill && points.length >= 3) {
                ctx.closePath();
                ctx.fillStyle = color.replace(')', ', 0.2)').replace('rgb', 'rgba');
                ctx.fill();
            }
            
            // 绘制边框
            ctx.strokeStyle = color;
            ctx.lineWidth = 2;
            ctx.stroke();
            
            // 绘制顶点
            points.forEach(point => {
                drawVertex(point.x, point.y, color);
            });
        }
        
        // 绘制顶点(单独提取方法,确保第一个点也能显示)
        function drawVertex(x, y, color = '#FF9800') {
            ctx.fillStyle = '#fff';
            ctx.beginPath();
            ctx.arc(x, y, 5, 0, Math.PI * 2);
            ctx.fill();
            
            ctx.strokeStyle = color;
            ctx.lineWidth = 2;
            ctx.stroke();
        }
        
        // 绘制检查点
        function drawPoint(x, y, color = '#ff0000') {
            ctx.fillStyle = color;
            ctx.beginPath();
            ctx.arc(x, y, 6, 0, Math.PI * 2);
            ctx.fill();
            
            ctx.strokeStyle = '#fff';
            ctx.lineWidth = 2;
            ctx.stroke();
        }
        
        // 更新围栏列表显示
        function updateFenceList() {
            fenceListDiv.innerHTML = '';
            
            fences.forEach((fence, index) => {
                const fenceItem = document.createElement('div');
                fenceItem.className = 'fence-item';
                fenceItem.innerHTML = `
                    围栏 ${index + 1} (${fence.length} 个顶点)
                    <button onclick="removeFence(${index})" style="width: auto; padding: 2px 5px; float: right; background-color: #f44336;">删除</button>
                `;
                fenceListDiv.appendChild(fenceItem);
            });
        }
        
        // 移除指定围栏
        function removeFence(index) {
            fences.splice(index, 1);
            redraw();
            updateFenceList();
        }
        
        // 重绘整个画布
        function redraw() {
            initCanvas();
            drawFences();
        }
        
        // 处理磁吸逻辑
        function handleMagnetism(x, y) {
            // 只有当已有至少一个点时才可能触发磁吸
            if (currentFence.length === 0) {
                return {x, y, magnetized: false};
            }
            
            const firstPoint = currentFence[0];
            const distance = getDistance({x, y}, firstPoint);
            
            // 如果在磁吸范围内,返回第一个点的坐标
            if (distance < MAGNET_DISTANCE) {
                return {
                    x: firstPoint.x,
                    y: firstPoint.y,
                    magnetized: true
                };
            }
            
            return {x, y, magnetized: false};
        }
        
        // 显示/隐藏磁吸提示
        function showMagnetHint(show, x, y) {
            if (show) {
                magnetHint.style.display = 'block';
                magnetHint.style.left = `${x + 10}px`;
                magnetHint.style.top = `${y - 20}px`;
            } else {
                magnetHint.style.display = 'none';
            }
        }
        
        // 事件监听 - 画布点击
        canvas.addEventListener('click', (e) => {
            if (!isDrawing) return;
            
            // 获取相对于画布的坐标
            const rect = canvas.getBoundingClientRect();
            let x = e.clientX - rect.left;
            let y = e.clientY - rect.top;
            
            // 应用磁吸效果(但第一个点不磁吸)
            const magnetResult = currentFence.length > 0 ? handleMagnetism(x, y) : {x, y, magnetized: false};
            x = magnetResult.x;
            y = magnetResult.y;
            
            // 添加点到当前围栏
            currentFence.push({x, y});
            
            // 启用完成按钮(至少有一个点就可以点击完成,但实际完成需要3个点)
            finishFenceBtn.disabled = false;
            
            // 如果触发了磁吸,自动完成围栏绘制
            if (magnetResult.magnetized && currentFence.length >= 3) {
                // 移除最后一个点(重复的第一个点),保持多边形闭合但不重复存储
                currentFence.pop();
                fences.push([...currentFence]);
                currentFence = [];
                isDrawing = false;
                startFenceBtn.disabled = false;
                finishFenceBtn.disabled = true;
                showMagnetHint(false);
                alert('围栏已通过磁吸自动闭合');
            }
            
            // 更新输入框
            xCoordInput.value = Math.round(x);
            yCoordInput.value = Math.round(y);
            
            // 重绘
            redraw();
            updateFenceList();
        });
        
        // 事件监听 - 鼠标移动(显示磁吸提示)
        canvas.addEventListener('mousemove', (e) => {
            if (!isDrawing || currentFence.length < 1) {
                showMagnetHint(false);
                return;
            }
            
            // 获取相对于画布的坐标
            const rect = canvas.getBoundingClientRect();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            
            // 检查是否在磁吸范围内
            const firstPoint = currentFence[0];
            const distance = getDistance({x, y}, firstPoint);
            const inMagnetRange = distance < MAGNET_DISTANCE;
            
            // 显示或隐藏磁吸提示
            showMagnetHint(inMagnetRange, e.clientX, e.clientY);
        });
        
        // 事件监听 - 开始绘制围栏
        startFenceBtn.addEventListener('click', () => {
            isDrawing = true;
            currentFence = [];
            startFenceBtn.disabled = true;
            finishFenceBtn.disabled = true; // 开始时禁用完成按钮,直到有第一个点
            alert('点击画布添加围栏顶点,第一个点将立即显示,当最后一个点靠近第一个点时会自动吸附闭合');
        });
        
        // 事件监听 - 完成当前围栏
        finishFenceBtn.addEventListener('click', () => {
            if (currentFence.length >= 3) {
                // 检查是否需要闭合(最后一个点是否与第一个点重合)
                const firstPoint = currentFence[0];
                const lastPoint = currentFence[currentFence.length - 1];
                if (getDistance(firstPoint, lastPoint) > 1) {
                    // 如果不重合,添加第一个点作为最后一个点使其闭合
                    currentFence.push(firstPoint);
                }
                fences.push([...currentFence]);
                currentFence = [];
                isDrawing = false;
                startFenceBtn.disabled = false;
                finishFenceBtn.disabled = true;
                redraw();
                updateFenceList();
            } else {
                alert('围栏至少需要3个顶点才能完成');
            }
        });
        
        // 事件监听 - 清除所有围栏
        clearAllBtn.addEventListener('click', () => {
            if (confirm('确定要清除所有围栏吗?')) {
                fences = [];
                currentFence = [];
                isDrawing = false;
                startFenceBtn.disabled = false;
                finishFenceBtn.disabled = true;
                redraw();
                updateFenceList();
            }
        });
        
        // 事件监听 - 检查点
        checkPointBtn.addEventListener('click', () => {
            const x = parseFloat(xCoordInput.value);
            const y = parseFloat(yCoordInput.value);
            
            if (isNaN(x) || isNaN(y) || x < 0 || x > canvas.width || y < 0 || y > canvas.height) {
                alert('请输入有效的坐标值 (0-' + canvas.width + ', 0-' + canvas.height + ')');
                return;
            }
            
            // 重绘以清除之前的检查点
            redraw();
            
            // 绘制检查点
            drawPoint(x, y);
            
            // 检查是否在围栏内
            const result = checkPointInAnyFence(x, y);
            
            // 显示结果
            if (result.inside) {
                resultDiv.textContent = `坐标 (${x}, ${y}) 在 围栏 ${result.fenceIndex + 1} 内部`;
                resultDiv.className = 'inside';
            } else {
                resultDiv.textContent = `坐标 (${x}, ${y}) 在所有围栏外部`;
                resultDiv.className = 'outside';
            }
        });
        
        // 初始化
        initCanvas();
    </script>
</body>
</html>
×