ArcGIS JS 基础教程(13):屏幕坐标与地理坐标互转

ArcGIS JS 基础教程(13):屏幕坐标与地理坐标互转

零、写在前面

📌 本系列教程完整目录ArcGIS JS 系列基础教程(100个项目常用热门功能)

💡 在线示例 :完整可运行的 HTML 示例,无需任何环境配置,可直接在浏览器中打开体验

🗂️ 专栏导航 :收藏 + 关注,专栏文章第一时间送达

❤️ 一键三连:点赞(给教程充电)+ 评论(提问必回)+ 收藏(下次再看)


一、功能介绍

在 WebGIS 开发中,经常需要在"用户鼠标点击的屏幕位置"和"真实地理坐标"之间进行转换。ArcGIS Maps SDK for JavaScript 提供了两个核心方法:

  • view.toMap(screenPoint) :将屏幕像素坐标(以视图容器左上角为原点)转换为地理坐标 Point(经纬度 + 高程)
  • view.toScreen(mapPoint) :将地理坐标 Point 转换为屏幕像素坐标 { x, y }

这两个方法是实现鼠标拾取、坐标标注、测量工具、点击添加标记、鼠标悬停信息提示等功能的基础。它们就像桥梁一样,把"用户看得到摸得着的屏幕"和"地图背后的地理空间"连接了起来。


二、功能实现

核心 API: view.toMap()view.toScreen(),均在 SceneView 实例上调用。

2.1 屏幕坐标 → 地理坐标(toMap)

将鼠标点击的像素位置转换为 WGS84 经纬度坐标:

javascript 复制代码
// 监听地图点击事件
view.on("click", (event) => {
    // event.x, event.y 是相对视图容器左上角的像素坐标
    const screenPoint = { x: event.x, y: event.y };

    // 转换为地理坐标
    const mapPoint = view.toMap(screenPoint);

    console.log(`屏幕像素:(${screenPoint.x}, ${screenPoint.y})`);
    console.log(`地理坐标:经度 ${mapPoint.longitude.toFixed(6)}, 纬度 ${mapPoint.latitude.toFixed(6)}`);
    console.log(`地面高程:${mapPoint.z?.toFixed(2) || "无"} 米`);
});

返回值说明:

  • 返回的是 Point 对象,包含 longitudelatitudez(海拔高度)
  • z 值仅在场景加载了高程数据时有效,否则为 undefined
  • 坐标系默认是 WGS84(EPSG:4326)

2.2 地理坐标 → 屏幕坐标(toScreen)

将已知的地理坐标转换为屏幕像素位置,常用于在特定地理位置上叠加 UI 元素:

javascript 复制代码
const Point = await $arcgis.import("@arcgis/core/geometry/Point.js");

// 创建地理坐标点
const mapPoint = new Point({
    longitude: 116.397,
    latitude: 39.917
});

// 转换为屏幕坐标
const screenPoint = view.toScreen(mapPoint);

console.log(`屏幕像素位置:x=${screenPoint.x}, y=${screenPoint.y}`);

⚠️ 注意: 如果地理坐标不在当前视野范围内,返回的屏幕坐标会超出视口范围(如 x < 0x > view.width)。

2.3 鼠标移动实时坐标

结合 pointer-move 事件实现鼠标位置实时显示坐标:

javascript 复制代码
view.on("pointer-move", (event) => {
    const screenPoint = { x: event.x, y: event.y };
    const mapPoint = view.toMap(screenPoint);

    // 更新 UI 显示当前鼠标所在位置的经纬度
    document.getElementById("coords").textContent =
        `经度 ${mapPoint.longitude.toFixed(6)}, 纬度 ${mapPoint.latitude.toFixed(6)}`;
});

2.4 点击添加标记

结合 toMap()GraphicsLayer,实现点击地图添加标记物:

javascript 复制代码
view.on("click", (event) => {
    const mapPoint = view.toMap({ x: event.x, y: event.y });

    // 在地理坐标处添加图形标记
    const graphic = new Graphic({
        geometry: mapPoint,
        symbol: {
            type: "point-3d",
            symbolLayers: [{
                type: "icon",
                resource: { primitive: "circle" },
                size: 16,
                material: { color: [255, 50, 50] }
            }]
        }
    });

    markerLayer.add(graphic);
});

2.5 屏幕坐标格式说明

屏幕坐标使用 { x, y } 对象格式,坐标系原点在视图容器的左上角

复制代码
(0, 0) ──────────────────→ x 增大(右)
  │
  │
  │
  ↓
  y 增大(下)
位置 屏幕坐标
视图容器左上角 { x: 0, y: 0 }
视图容器右下角 { x: view.width, y: view.height }
视图容器中心 { x: view.width/2, y: view.height/2 }

三、功能应用

应用场景 方法 说明
点击地图获取坐标 toMap() 用户点击后显示/记录该点的经纬度
鼠标悬停显示坐标 toMap() + pointer-move 底部状态栏实时显示鼠标所处坐标
点击添加 POI 标记 toMap() + Graphic 在地图点击位置放置标记(Pin)
坐标输入反向定位 toScreen() + goTo 用户输入经纬度后飞到该位置
地理标签 UI 叠加 toScreen() 在建筑/POI 上方悬浮 HTML 标签
测量工具 toMap() 点击两个点计算距离
屏幕截图区域定位 toScreen() 确定截图框的地理范围
数据采集工具 toMap() 野外调研时点击记录采样点坐标

四、核心代码

📦 完整代码 已保存至 sample/lesson13_coordinate_conversion.html,可直接在浏览器打开。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>第13课:屏幕坐标与地理坐标互转</title>
    <link rel="stylesheet" href="https://js.arcgis.com/5.0/esri/themes/light/main.css">
    <script type="module" src="https://js.arcgis.com/5.0/"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: "Microsoft YaHei", sans-serif;
        }

        #mapContainer {
            width: 100vw;
            height: 100vh;
        }

        .page-title {
            position: absolute;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(255, 255, 255, 0.95);
            padding: 10px 24px;
            border-radius: 6px;
            font-size: 18px;
            font-weight: bold;
            z-index: 100;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
        }

        .control-panel {
            position: absolute;
            top: 80px;
            right: 20px;
            background: rgba(255, 255, 255, 0.95);
            padding: 16px;
            border-radius: 8px;
            box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
            z-index: 100;
            min-width: 320px;
        }

        .control-panel h3 {
            margin: 0 0 8px 0;
            font-size: 14px;
            color: #333;
        }

        .section {
            margin-bottom: 12px;
            padding-bottom: 10px;
            border-bottom: 1px solid #eee;
        }

        .section:last-child {
            border-bottom: none;
            margin-bottom: 0;
        }

        .info-row {
            display: flex;
            gap: 8px;
            margin-bottom: 4px;
            font-size: 12px;
        }

        .info-row .label {
            color: #666;
            min-width: 60px;
        }

        .info-row .value {
            font-weight: bold;
            color: #1890ff;
            font-family: monospace;
        }

        .btn-row {
            display: flex;
            gap: 8px;
            margin-top: 8px;
        }

        .btn-row button {
            flex: 1;
            padding: 7px 0;
            border: 1px solid #d9d9d9;
            border-radius: 4px;
            background: white;
            cursor: pointer;
            font-size: 12px;
            transition: all 0.2s;
        }

        .btn-row button:hover {
            border-color: #1890ff;
            color: #1890ff;
        }

        .btn-row button.primary {
            background: #1890ff;
            color: white;
            border-color: #1890ff;
        }

        .btn-row button.danger {
            border-color: #ff4d4f;
            color: #ff4d4f;
        }

        .form-group {
            margin-bottom: 8px;
        }

        .form-group label {
            display: block;
            font-size: 12px;
            color: #666;
            margin-bottom: 3px;
        }

        .form-group input {
            width: 100%;
            padding: 5px 8px;
            border: 1px solid #d9d9d9;
            border-radius: 4px;
            font-size: 12px;
        }

        .form-group input:focus {
            border-color: #1890ff;
            outline: none;
        }

        .status-bar {
            position: absolute;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(0, 0, 0, 0.75);
            color: white;
            padding: 8px 20px;
            border-radius: 20px;
            font-size: 13px;
            z-index: 100;
            display: flex;
            gap: 20px;
            white-space: nowrap;
        }

        .status-bar .coord-item {
            font-family: monospace;
        }

        .status-bar .coord-item span {
            color: #40a9ff;
            font-weight: bold;
        }

        .crosshair {
            position: absolute;
            z-index: 99;
            pointer-events: none;
            width: 30px;
            height: 30px;
            border-left: 2px dashed rgba(24, 144, 255, 0.7);
            border-top: 2px dashed rgba(24, 144, 255, 0.7);
            display: none;
        }

        .click-hint {
            position: absolute;
            top: 60px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(24, 144, 255, 0.9);
            color: white;
            padding: 4px 12px;
            border-radius: 3px;
            font-size: 12px;
            z-index: 101;
            pointer-events: none;
            display: none;
        }
    </style>
</head>
<body>
<h1 class="page-title">第13课:屏幕坐标与地理坐标互转</h1>

<div class="crosshair" id="crosshair"></div>
<div class="click-hint" id="clickHint">点击地图获取该点地理坐标</div>

<div class="control-panel">
    <div class="section">
        <h3>📍 点击地图获取坐标(toMap)</h3>
        <div class="info-row"><span class="label">屏幕坐标:</span><span class="value" id="screenCoords">--</span></div>
        <div class="info-row"><span class="label">经度:</span><span class="value" id="lngCoord">--</span></div>
        <div class="info-row"><span class="label">纬度:</span><span class="value" id="latCoord">--</span></div>
        <div class="info-row"><span class="label">高程:</span><span class="value" id="elevCoord">--</span></div>
    </div>

    <div class="section">
        <h3>🎯 坐标反查(输入坐标定位)</h3>
        <div class="form-group">
            <label>经度(如 116.397):</label>
            <input type="number" id="inputLng" placeholder="118.080" step="0.001">
        </div>
        <div class="form-group">
            <label>纬度(如 39.917):</label>
            <input type="number" id="inputLat" placeholder="30.330" step="0.001">
        </div>
        <div class="btn-row">
            <button id="btnLocate" class="primary">🚀 飞到坐标</button>
            <button id="btnToScreen">📺 查屏幕位置</button>
        </div>
    </div>

    <div class="section">
        <h3>🔴 标记管理</h3>
        <div class="btn-row">
            <button id="btnClearMarks" class="danger">🗑 清除所有标记</button>
        </div>
    </div>
</div>

<div class="status-bar" id="statusBar">
    <div class="coord-item">鼠标:<span id="hoverLng">--</span>, <span id="hoverLat">--</span></div>
    <div>|</div>
    <div class="coord-item">高程:<span id="hoverElev">--</span>m</div>
</div>

<div id="mapContainer"></div>

<script type="module">
    const Map = await $arcgis.import("@arcgis/core/Map.js");
    const SceneView = await $arcgis.import("@arcgis/core/views/SceneView.js");
    const GraphicsLayer = await $arcgis.import("@arcgis/core/layers/GraphicsLayer.js");
    const IntegratedMesh3DTilesLayer = await $arcgis.import("@arcgis/core/layers/IntegratedMesh3DTilesLayer.js");

    const Graphic = await $arcgis.import("@arcgis/core/Graphic.js");
    const Point = await $arcgis.import("@arcgis/core/geometry/Point.js");
    const getTianditu = await $arcgis.import("https://openlayers.vip/examples/resources/tianditu.js");

    // 天地图底图 + 高程
    const vecLayers = getTianditu.default({type: "vec_w"});
    const map = new Map({
        basemap: {baseLayers: [vecLayers.base, vecLayers.anno]},
        ground: {
            surface: {
                elevationLayers: [{
                    url: "https://www.geosceneonline.cn/image/rest/services/OpenData/ChinaTerrain3D/ImageServer/"
                }]
            }
        }
    });

    const view = new SceneView({
        container: "mapContainer",
        map: map,
        camera: {
            position: {longitude: 118.080, latitude: 30.330, z: 8000},
            heading: 0,
            tilt: 50
        }
    });
    window.view = view;


    const layer = new IntegratedMesh3DTilesLayer({
        url: "http://openlayers.vip/cesium/3dtile/xianggang_1.1/tileset.json",
    });
    map.add(layer);

    await view.when();

    // 等待 3D Tiles 图层加载,获取其范围
    await view.whenLayerView(layer);

    // 定位至图层范围
    view.goTo({
        target: layer.fullExtent,
        tilt: 55
    }, {duration: 1500});


    // 标记图层
    const markerLayer = new GraphicsLayer({title: "点击标记"});
    map.add(markerLayer);

    // DOM 元素
    const crosshair = document.getElementById("crosshair");
    const clickHint = document.getElementById("clickHint");

    // ===== toMap:屏幕坐标 → 地理坐标 =====
    function screenToMap(screenX, screenY) {
        return view.toMap({x: screenX, y: screenY});
    }

    // ===== toScreen:地理坐标 → 屏幕坐标 =====
    function mapToScreen(longitude, latitude) {
        const pt = new Point({longitude, latitude});
        return view.toScreen(pt);
    }

    // ===== 更新右侧面板坐标信息 =====
    function updateClickInfo(screenX, screenY, mapPoint) {
        document.getElementById("screenCoords").textContent =
            `(${Math.round(screenX)}, ${Math.round(screenY)})`;
        document.getElementById("lngCoord").textContent =
            mapPoint.longitude.toFixed(6) + "°";
        document.getElementById("latCoord").textContent =
            mapPoint.latitude.toFixed(6) + "°";
        document.getElementById("elevCoord").textContent =
            (mapPoint.z != null) ? mapPoint.z.toFixed(2) + " m" : "无高程数据";
    }

    // ===== 添加红色双圈标记 =====
    function addMarker(mapPoint) {
        // 内圈实心红点
        markerLayer.add(new Graphic({
            geometry: mapPoint,
            symbol: {
                type: "point-3d",
                symbolLayers: [{
                    type: "icon",
                    resource: {primitive: "circle"},
                    size: 18,
                    material: {color: [255, 50, 50]},
                    outline: {color: [255, 255, 255], size: 1.5}
                }]
            }
        }));
        // 外圈半透明白色
        markerLayer.add(new Graphic({
            geometry: mapPoint,
            symbol: {
                type: "point-3d",
                symbolLayers: [{
                    type: "icon",
                    resource: {primitive: "circle"},
                    size: 28,
                    material: {color: [255, 255, 255, 0.4]}
                }]
            }
        }));
    }

    // ===== 初始化 =====
    view.when(() => {
        console.log("场景加载完成");

        // ---------- 鼠标移动:实时显示坐标 ----------
        view.on("pointer-move", (event) => {
            const mapPoint = screenToMap(event.x, event.y);
            document.getElementById("hoverLng").textContent = mapPoint.longitude.toFixed(6);
            document.getElementById("hoverLat").textContent = mapPoint.latitude.toFixed(6);
            document.getElementById("hoverElev").textContent =
                (mapPoint.z != null) ? Math.round(mapPoint.z) : "--";

            // 十字准星跟随鼠标
            crosshair.style.left = (event.x - 15) + "px";
            crosshair.style.top = (event.y - 15) + "px";
            crosshair.style.display = "block";
        });

        view.on("pointer-leave", () => {
            crosshair.style.display = "none";
        });

        // ---------- 点击:获取坐标 + 添加标记 ----------
        view.on("click", (event) => {
            const mapPoint = screenToMap(event.x, event.y);
            updateClickInfo(event.x, event.y, mapPoint);
            addMarker(mapPoint);

            // 显示点击反馈
            clickHint.style.display = "block";
            clickHint.style.left = (event.x - 100) + "px";
            clickHint.style.top = (event.y - 40) + "px";
            clickHint.textContent =
                `✅ ${mapPoint.longitude.toFixed(4)}, ${mapPoint.latitude.toFixed(4)}`;
            setTimeout(() => {
                clickHint.style.display = "none";
            }, 1500);
        });

        // ---------- 按钮:飞到指定坐标 ----------
        document.getElementById("btnLocate").addEventListener("click", () => {
            const lng = parseFloat(document.getElementById("inputLng").value);
            const lat = parseFloat(document.getElementById("inputLat").value);
            if (isNaN(lng) || isNaN(lat)) {
                alert("请输入有效的经纬度");
                return;
            }
            view.goTo({
                center: [lng, lat],
                zoom: 16
            }, {
                duration: 2000,
                easing: "cubic-out"
            });
        });

        // ---------- 按钮:坐标反查屏幕位置 ----------
        document.getElementById("btnToScreen").addEventListener("click", () => {
            const lng = parseFloat(document.getElementById("inputLng").value);
            const lat = parseFloat(document.getElementById("inputLat").value);
            if (isNaN(lng) || isNaN(lat)) {
                alert("请输入有效的经纬度");
                return;
            }
            const screenPt = mapToScreen(lng, lat);
            const inView = screenPt.x >= 0 && screenPt.x <= view.width
                && screenPt.y >= 0 && screenPt.y <= view.height;
            alert(
                `地理坐标:(${lng}, ${lat})\n` +
                `屏幕坐标:(${Math.round(screenPt.x)}, ${Math.round(screenPt.y)})\n` +
                (inView ? "✅ 该坐标在视野范围内" : "⚠️ 该坐标不在当前视野范围内")
            );
        });

        // ---------- 按钮:清除标记 ----------
        document.getElementById("btnClearMarks").addEventListener("click", () => {
            markerLayer.removeAll();
            document.getElementById("screenCoords").textContent = "--";
            document.getElementById("lngCoord").textContent = "--";
            document.getElementById("latCoord").textContent = "--";
            document.getElementById("elevCoord").textContent = "--";
        });
    });
</script>
</body>
</html>

五、在线示例

🔗 在线体验地址(GitHub资源,等待时间较长,或者架梯子)https://southjor.github.io/arcgis-examples/lessons/lesson13.html

操作说明

  1. 鼠标移动:在场景中滑动鼠标,底部状态栏实时显示当前鼠标位置的经纬度和地面高程
  2. 点击地图:点击任意位置,右侧面板显示该点的屏幕坐标、经纬度和高程,同时在场景中添加红色标记
  3. 坐标输入 :在经纬度输入框中输入坐标,点击「🚀 飞到坐标」通过 goTo 飞到该位置
  4. 坐标反查:输入坐标后点击「📺 查屏幕位置」,弹窗显示该地理坐标对应的屏幕像素位置
  5. 清除标记:点击「🗑 清除所有标记」移除所有已添加的标记点
  6. 十字准星:鼠标移动时蓝色虚线十字标跟随,帮助精确定位

六、关键API说明

API 参数 返回值 说明
view.toMap(screenPoint) { x, y } Point 屏幕像素 → 地理坐标(含 longitude/latitude/z)
view.toScreen(mapPoint) Point { x, y } 地理坐标 → 屏幕像素位置
event.x / event.y --- number 鼠标事件中相对视图容器的像素坐标
mapPoint.z --- number | undefined 地面高程(米),需加载高程数据

常见坑点

问题 原因 解决方案
toMap() 返回的 z 为 undefined 未加载高程数据( ground 未配置) 在 Map 中配置 ground.surface.elevationLayers
toScreen() 返回坐标超出范围 地理坐标不在当前视野内 先判断 x < 0x > view.width 确认是否可见
点击获取的坐标和鼠标位置不一致 地图可能被 CSS 偏移(padding/margin) 使用 event.x/event.y(已自动处理偏移)
多次点击标记重叠 每次点击都直接在地理坐标处添加 无影响------toMap 返回的是精确坐标;如需去重可先查询已有标记

七、系列导航

⬅️ 上一篇ArcGIS JS 基础教程(12):视图截图 takeScreenshot

➡️ 下一篇ArcGIS JS 基础教程(14):Map、View 与 Layer 关系


💡 小贴士toMap()toScreen() 是一对"双向桥"。记住三个关键场景:(1) 点击拾取toMap();(2) 标签叠加toScreen();(3) 实时跟随pointer-move + toMap()。结合这三招,你可以在三维地图上实现任何坐标交互功能。