ArcGIS JS 基础教程(38):StreamLayer 实时流数据图层

ArcGIS JS 基础教程(38):StreamLayer 实时流数据图层

零、写在前面

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

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

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

❤️ 一键三连:点赞 + 评论 + 收藏


一、功能介绍

StreamLayer 用来做实时流数据可视化,车辆 GPS、传感器点位、飞行器轨迹这类数据都适合。它平时连的是流服务(Stream Service) ,消息由服务端持续推过来;要是手上没有流服务(比如本地做演示),可以走客户端模式 ,自己调 streamLayer.sendMessageToClient({ type: "features", features }) 把点位推给图层,不需要后端。

本课要做的几件事:

  • StreamLayer 搭一个客户端实时点位图层,不依赖服务端;
  • trackIdFieldTRACKID 相同的消息归并成一辆车,用 purgeOptions.maxObservations 决定每条轨迹保留几个观测点;
  • setInterval 模拟服务端推位置,做出流动效果;
  • 每条推送要素带上唯一的 OBJECTID,满足客户端流图层的字段要求。

参考:官方 StreamLayerStreamLayer 客户端推送示例

💡 三维要点 :客户端 StreamLayer 的必需项是 geometryTypeobjectIdFieldfields,以及 timeInfo.trackIdField(该字段必须在 fields 里)。spatialReference 不属于必需项,官方客户端示例也会传;客户端模式不做重投影,推送几何的空间参考要和视图一致。用 elevationInfo 控制点位离地方式。


二、功能实现

2.1 构建客户端 StreamLayer

javascript 复制代码
const streamLayer = new StreamLayer({
    objectIdField: "OBJECTID",
    fields: [
        { name: "OBJECTID", alias: "OBJECTID", type: "oid" },
        { name: "TRACKID", alias: "轨迹编号", type: "long" },
        { name: "SPEED", alias: "速度", type: "double" }
    ],
    timeInfo: { trackIdField: "TRACKID" },  // 按轨迹编号归并同一车辆
    geometryType: "point",
    spatialReference: { wkid: 4326 },
    updateInterval: 100,
    // 每条轨迹保留 40 个观测点(默认 1,即只显示最新位置);purgeOptions 加载后不可修改
    purgeOptions: { displayCount: 2000, maxObservations: 40 },
    elevationInfo: { mode: "relative-to-ground" },
    renderer: { /* point-3d 球体符号 */ }
});
map.add(streamLayer);

purgeOptions 在图层加载后不可修改,轨迹保留量要在这里一次配好。

2.2 客户端推送一帧

javascript 复制代码
let objectIdCounter = 1; // 每条要素的 OBJECTID 必须唯一

function pushFrame() {
    const features = cars.map(c => {
        c.lon += c.dLon; c.lat += c.dLat;   // 移动
        return {
            geometry: { x: c.lon, y: c.lat, z: c.z, spatialReference: { wkid: 4326 } },
            attributes: { OBJECTID: objectIdCounter++, TRACKID: c.id, SPEED: Math.round(20 + Math.random() * 40) }
        };
    });
    // 官方客户端流式推送 API
    streamLayer.sendMessageToClient({ type: "features", features });
}
setInterval(pushFrame, 500);

2.3 清空轨迹与轨迹保留量

清空用客户端的 clear 消息(官方客户端消息类型共 featuresdeleteclear 三种):

javascript 复制代码
streamLayer.sendMessageToClient({ type: "clear" });

每条轨迹保留几个观测点由 purgeOptions.maxObservations 决定,默认值 1,也就是只显示最新位置。它属于 purgeOptions,官方说明图层加载后不可修改,所以要在构造时就配好:

javascript 复制代码
purgeOptions: { displayCount: 2000, maxObservations: 40 }

需要按轨迹取最新观测时,用官方的 StreamLayerView.queryLatestObservations()(前提是设置了 timeInfo.trackIdField)。


三、功能应用

应用场景 说明
车辆 GPS 实时监控 每帧推送坐标,屏幕上「流动」
传感器/无人机轨迹 按 TRACKID 归并,连续显示一条轨迹
无服务演示 sendMessageToClient 前端模拟流
轨迹抽稀 purgeOptions.maxObservations 决定每条轨迹保留几个观测点(构造期配置)

四、核心代码

📦 完整代码 已保存至 sample/lesson36_stream_layer.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>第36课:StreamLayer 实时流数据图层</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: 260px;
        }
        .control-panel h3 { margin: 0 0 8px 0; font-size: 14px; color: #333; }
        .btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
        .btn-row button {
            flex: 1; min-width: 70px; padding: 6px 0;
            border: 1px solid #d9d9d9; border-radius: 4px;
            background: white; cursor: pointer; font-size: 12px;
        }
        .btn-row button:hover { border-color: #1890ff; color: #1890ff; }
        .btn-row button.on { background: #1890ff; color: white; border-color: #1890ff; }
        .info-card {
            margin-top: 10px; padding: 10px 12px;
            background: #f0f5ff; border-radius: 6px;
            border-left: 3px solid #1890ff; font-size: 12px; line-height: 1.8;
        }
        .info-card .val { font-weight: bold; color: #1890ff; }
        .status-text {
            position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
            background: rgba(0,0,0,0.7); color: white; padding: 8px 20px;
            border-radius: 20px; font-size: 13px; z-index: 100; pointer-events: none;
            white-space: nowrap;
        }
    </style>
</head>
<body>
<h1 class="page-title">第36课:StreamLayer 实时流数据图层</h1>

<div class="control-panel">
    <h3>🚌 实时车辆流(客户端模拟)</h3>
    <div class="btn-row">
        <button id="btnPlay" class="on">⏸️ 暂停推送</button>
        <button id="btnClear">🧹 清空轨迹</button>
    </div>
    <div class="info-card">
        <div>推送状态:<span class="val" id="playState">运行中</span></div>
        <div>车辆数量:<span class="val" id="carCount">0</span></div>
        <div>累计消息:<span class="val" id="msgCount">0</span></div>
    </div>
</div>

<div class="status-text" id="statusText">StreamLayer | 客户端流式推送 3D 实时点位</div>

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

<script type="module">
    const [Map, SceneView, StreamLayer, Extent] = await $arcgis.import([
        "@arcgis/core/Map.js",
        "@arcgis/core/views/SceneView.js",
        "@arcgis/core/layers/StreamLayer.js",
        "@arcgis/core/geometry/Extent.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] } });

    const view = new SceneView({
        container: "mapContainer", map: map,
        camera: {
            position: { longitude: 121.498, latitude: 31.225, z: 3500 },
            heading: 20, tilt: 60
        }
    });

    // ===== 客户端 StreamLayer(无需流服务,通过 sendMessageToClient 推送) =====
    const streamLayer = new StreamLayer({
        objectIdField: "OBJECTID",
        fields: [
            { name: "OBJECTID", alias: "OBJECTID", type: "oid" },
            { name: "TRACKID", alias: "轨迹编号", type: "long" },
            { name: "SPEED", alias: "速度", type: "double" }
        ],
        timeInfo: { trackIdField: "TRACKID" },  // 按轨迹编号归并同一车辆
        geometryType: "point",
        spatialReference: { wkid: 4326 },
        updateInterval: 100,
        // 每条轨迹保留 40 个观测点(默认 1,即只显示最新位置);purgeOptions 加载后不可修改
        purgeOptions: { displayCount: 2000, maxObservations: 40 },
        elevationInfo: { mode: "relative-to-ground" },
        renderer: {
            type: "simple",
            symbol: {
                type: "point-3d",
                symbolLayers: [{
                    type: "object",
                    resource: { primitive: "sphere" },
                    width: 120, height: 120, depth: 120,
                    material: { color: [255, 90, 0, 0.9] }
                }]
            }
        }
    });
    map.add(streamLayer);

    // ===== 模拟数据:8 辆车沿各自方向匀速移动 =====
    const CAR_NUM = 8;
    const cars = [];
    for (let i = 0; i < CAR_NUM; i++) {
        cars.push({
            id: i + 1,
            lon: 121.47 + Math.random() * 0.06,
            lat: 31.20 + Math.random() * 0.05,
            z: 100 + Math.random() * 300,
            dLon: (Math.random() - 0.5) * 0.0006,
            dLat: (Math.random() - 0.5) * 0.0006
        });
    }

    let objectIdCounter = 1; // 每条推送要素都需要唯一 OBJECTID(官方示例用自增计数器)
    let msgCount = 0;
    let playing = true;
    let timer = null;

    function pushFrame() {
        const features = cars.map(c => {
            c.lon += c.dLon;
            c.lat += c.dLat;
            // 越界折返,保证车辆始终在场景内
            if (c.lon < 121.46 || c.lon > 121.54) c.dLon *= -1;
            if (c.lat < 31.19 || c.lat > 31.26) c.dLat *= -1;
            return {
                geometry: { x: c.lon, y: c.lat, z: c.z, spatialReference: { wkid: 4326 } },
                attributes: { OBJECTID: objectIdCounter++, TRACKID: c.id, SPEED: Math.round(20 + Math.random() * 40) }
            };
        });
        // 官方客户端流式推送 API
        streamLayer.sendMessageToClient({ type: "features", features });
        msgCount += features.length;
        document.getElementById("carCount").textContent = CAR_NUM;
        document.getElementById("msgCount").textContent = msgCount;
    }

    function startTimer() { timer = setInterval(pushFrame, 500); }
    function stopTimer() { if (timer) { clearInterval(timer); timer = null; } }

    await view.when();
    startTimer();
    setStatus("StreamLayer 已连接 | 每 0.5 秒推送一帧位置");
    // 默认定位:框选车辆活动区域(车辆在此范围内移动)
    // goTo 的目标要传几何实例,官方 GoToTarget 里没有 {type:"extent"} 这种 JSON 形态
    const region = new Extent({
        xmin: 121.45, xmax: 121.55,
        ymin: 31.18, ymax: 31.27,
        spatialReference: { wkid: 4326 }
    });
    view.goTo(region, { duration: 1500 }).catch(() => {});

    function setStatus(msg) { document.getElementById("statusText").textContent = msg; }

    // 播放 / 暂停
    document.getElementById("btnPlay").addEventListener("click", function () {
        playing = !playing;
        if (playing) { startTimer(); } else { stopTimer(); }
        this.classList.toggle("on", playing);
        this.textContent = playing ? "⏸️ 暂停推送" : "▶️ 继续推送";
        document.getElementById("playState").textContent = playing ? "运行中" : "已暂停";
        setStatus(playing ? "推送已恢复" : "推送已暂停");
    });

    // 清空轨迹(发送 clear 消息)
    document.getElementById("btnClear").addEventListener("click", () => {
        streamLayer.sendMessageToClient({ type: "clear" });
        msgCount = 0;
        document.getElementById("msgCount").textContent = 0;
        setStatus("轨迹已清空");
    });
</script>
</body>
</html>

五、在线示例

🔗 在线体验https://southjor.github.io/arcgis-examples/lessons/lesson36.html

操作说明

  1. 场景里加载 8 辆车,每 0.5 秒推送一帧位置,点位持续流动。
  2. 「暂停推送 / 继续推送」控制数据流入;「清空轨迹」发送 clear 消息重置。
  3. 每条轨迹保留 40 个观测点(purgeOptions.maxObservations),车身后方会拖出一条轨迹。

六、关键 API 说明

API 说明
StreamLayer 实时流图层(客户端模式无需服务端)
streamLayer.sendMessageToClient({type:"features",features}) 前端推送一帧点位
streamLayer.sendMessageToClient({type:"clear"}) 清空已推送轨迹
timeInfo.trackIdField 按轨迹编号归并同一目标
purgeOptions.displayCount 要素的总体最大数量
purgeOptions.maxObservations 一条轨迹中保留的最大观测数,默认 1(图层加载后不可修改)
StreamLayerView.queryLatestObservations() 按轨迹取最新观测(需设置 timeInfo.trackIdField
Extent + view.goTo(extent) 定位到指定范围,目标须为几何实例

参考链接: StreamLayer API


七、系列导航

⬅️ 上一篇ArcGIS JS 基础教程(35):VoxelLayer 时间维度动画

➡️ 下一篇ArcGIS JS 基础教程(37):客户端要素创建 FeatureLayer


💡 小贴士 :客户端模式下,用 sendMessageToClient 可以直接把现有数据源接到图层上,WebSocket、定时器、接口轮询都行,不用专门搭流服务。配合 trackIdFieldpurgeOptions.maxObservations,同一目标连续的位置会连成一条轨迹。

相关推荐
赵钰老师1 天前
基于ArcGIS Pro、R、INVEST等多技术融合下生态系统服务权衡与协同动态分析
python·arcgis·数据分析·r语言
梦想的初衷~2 天前
MaxEnt 物种分布建模学习手册|理论 + R 语言 + ArcGIS 全套实操教程
arcgis·环境变量·r 语言·maxent 模型·物种分布建模·生态学研究·科研教程
jiushidt7 天前
ArcGIS Pro Add‑in 打包流程详解
arcgis·c#·.net·arcgispro
蓝悦无人机7 天前
《地面站软件开发》-第2章ArcGIS Maps SDK for Qt 改变视角
arcgis·上位机·地面站·qml·qt6·qt quick·天地图
bug嘛我经常写8 天前
MIKE21结合ArcGIS制作随时间变化的降水文件(.dfs2)-笔记
经验分享·笔记·arcgis
paiooo9 天前
用 XML 保存 LLM 会话得到高可读性会话
arcgis
Q一件事9 天前
ArcGIS中TypeError: can‘t multiply sequence by non-int of type ‘str‘错误
前端·javascript·arcgis
萧行之10 天前
Observable Plot 源码深度解析——1 认识 Observable Plot
arcgis
ノBye~10 天前
Codex CLI使用记录
arcgis