ArcGIS JS 基础教程(38):StreamLayer 实时流数据图层
零、写在前面
📌 本系列教程完整目录 :ArcGIS JS 系列基础教程(100个项目常用热门功能)
💡 在线示例 :完整可运行的 HTML 示例,无需任何环境配置,可直接在浏览器中打开体验
🗂️ 专栏导航 :收藏 + 关注,专栏文章第一时间送达
❤️ 一键三连:点赞 + 评论 + 收藏
一、功能介绍
StreamLayer 用来做实时流数据可视化,车辆 GPS、传感器点位、飞行器轨迹这类数据都适合。它平时连的是流服务(Stream Service) ,消息由服务端持续推过来;要是手上没有流服务(比如本地做演示),可以走客户端模式 ,自己调 streamLayer.sendMessageToClient({ type: "features", features }) 把点位推给图层,不需要后端。
本课要做的几件事:
- 用
StreamLayer搭一个客户端实时点位图层,不依赖服务端; - 用
trackIdField把TRACKID相同的消息归并成一辆车,用purgeOptions.maxObservations决定每条轨迹保留几个观测点; - 用
setInterval模拟服务端推位置,做出流动效果; - 每条推送要素带上唯一的
OBJECTID,满足客户端流图层的字段要求。
参考:官方 StreamLayer | StreamLayer 客户端推送示例
💡 三维要点 :客户端
StreamLayer的必需项是geometryType、objectIdField与fields,以及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 消息(官方客户端消息类型共 features、delete、clear 三种):
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

操作说明:
- 场景里加载 8 辆车,每 0.5 秒推送一帧位置,点位持续流动。
- 「暂停推送 / 继续推送」控制数据流入;「清空轨迹」发送
clear消息重置。- 每条轨迹保留 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
七、系列导航
💡 小贴士 :客户端模式下,用
sendMessageToClient可以直接把现有数据源接到图层上,WebSocket、定时器、接口轮询都行,不用专门搭流服务。配合trackIdField和purgeOptions.maxObservations,同一目标连续的位置会连成一条轨迹。